diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6c2ac83 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [initialcommit-com] 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 ccd13ef..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 developement 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a4cd4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3 + +WORKDIR /usr/src/git-sim + +RUN apt update + +RUN apt -y install build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg + +RUN pip3 install manim + +RUN pip3 install git-sim + +ENTRYPOINT [ "git-sim" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE index ede9753..d159169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,339 @@ -MIT License - -Copyright (c) 2022 Initial Commit LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. 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 289a8bb..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,26 +21,38 @@ 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 - Animation only: Add custom branded intro/outro sequences if desired - Animation only: Speed up or slow down animation speed as desired ## Quickstart +Note: If you prefer to install git-sim with Docker, skip steps (1) and (2) here and jump to the [Docker installation](#docker-installation) section below, then come back here to step (3). 1) **Install Manim and its dependencies for your OS / environment:** - [Install Manim on Windows](https://docs.manim.community/en/stable/installation/windows.html) @@ -59,15 +80,57 @@ $ cd path/to/git/repo $ git-sim [global options] [subcommand options] ``` +Optional: If you don't have an existing Git repo to simulate commands on, use the bundled [git-dummy](https://github.com/initialcommit-com/git-dummy) command to generate a dummy Git repo with the desired number of branches and commits to simulate operations on with git-sim: + +```console +$ git-dummy --name="dummy-repo" --branches=3 --commits=10 +$ cd dummy-repo +$ git-sim [global options] [subcommand options] +``` + +Or if you want to do it all in a single command: + +```console +$ git-dummy --no-subdir --branches=3 --commits=10 && git-sim [global options] [subcommand options] +``` + 5) Simulated output will be created as a `.jpg` file. Output files are named using the subcommand executed combined with a timestamp, and by default are stored in a subdirectory called `git-sim_media/`. The location of this subdirectory is customizable using the command line flag `--media-dir=path/to/output`. Note that when the `--animate` global flag is used, render times will be much longer and a `.mp4` video output file will be produced. -6) See global help for list of global options/flags and subcommands: +6) For convenience, environment variables can be set for any global command-line option available in git-sim. All environment variables start with `git_sim_` followed by the name of the option. + +For example, the `--media-dir` option can be set as an environment variable like: + +```console +$ export git_sim_media_dir=~/Desktop +``` + +Similarly, the `--speed` option can be set like: + +```console +$ export git_sim_speed=2 +``` + +Boolean flags can be set like: + +```console +$ export git_sim_light_mode=true +``` + +In general: + +```console +$ export git_sim_option_name=option_value +``` + +Explicitly specifying options at the command-line takes precedence over the corresponding environment variable values. + +7) See global help for list of global options/flags and subcommands: ```console $ git-sim -h ``` -7) See subcommand help for list of options/flags for a specific subcommand: +8) See subcommand help for list of options/flags for a specific subcommand: ```console $ git-sim -h @@ -79,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] @@ -87,42 +151,40 @@ $ git-sim [global options] [subcommand options] The `[global options]` apply to the overarching `git-sim` simulation itself, including: -`--light-mode`: Use a light mode color scheme instead of default dark mode. -`--animate`: Instead of outputting a static image, animate the Git command behavior in a .mp4 video. -`--disable-auto-open, -d`: Disable the automatic opening of the image/video file after generation. -`--reverse, -r`: Display commit history in the reverse direction. -`--video-format`: Output format for the video file, i.e. `mp4` or `webm`. Default output format is `mp4`. +`-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. +`-d`: Disable the automatic opening of the image/video file after generation. Useful to avoid errors in console mode with no GUI. +`--light-mode`: Use a light mode color scheme instead of default dark mode. +`--reverse, -r`: Display commit history in the reverse direction. +`--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. +`--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`): -`--speed=n`: Set the multiple of animation speed of the output simulation, `n` can be an integer or float, default is 1. +`--video-format`: Output format for the video file, i.e. `mp4` or `webm`. Default output format is `mp4`. +`--speed=n`: Set the multiple of animation speed of the output simulation, `n` can be an integer or float, default is 1.5. `--low-quality`: Render the animation in low quality to speed up creation time, recommended for non-presentation use. `--show-intro`: Add an intro sequence with custom logo and title. `--show-outro`: Add an outro sequence with custom logo and text. `--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` - -- Simulated output will show the most recent 5 commits on the active branch by default - -![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 ... ` @@ -132,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 ` +### 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 working directory and staged files will be included -- Simulated output will show files being moved to 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]` @@ -187,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 ` @@ -196,29 +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 +- 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 @@ -359,7 +541,7 @@ Optionally, set the environment variable `git_sim_media_dir` to set a global def $ export git_sim_media_dir=path/to/media/directory $ git-sim status ``` -Note: `--media-dir` takes precedence over the environment variable. If you set the environment and still provide the argument, you'll find the media in the path provided by `--media-dir`. +Note: `--media-dir` takes precedence over the environment variable. If you set the environment variable and still provide the argument, you'll find the media in the path provided by `--media-dir`. Generate output video in low quality to speed up rendering time (useful for repeated testing, must include `--animate`): @@ -374,6 +556,32 @@ See **Quickstart** section for details on installing manim and other dependencie $ pip3 install git-sim ``` +## Docker installation + +1) Clone down the git-sim repository: + +```console +$ git clone https://github.com/initialcommit-com/git-sim.git +``` + +2) Browse into the `git-sim` folder and build the Docker image: + +```console +$ docker build -t git-sim . +``` + +3) Run git-sim commands as follows: + - Windows: `docker run --rm -v %cd%:/usr/src/git-sim git-sim [global options] [subcommand options]` + - MacOS / Linux: `docker run --rm -v $(pwd):/usr/src/git-sim git-sim [global options] [subcommand options]` + +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 "$@"; } +``` + +This will enable you to run git-sim subcommands as [described above](#commands). + ## Learn More Learn more about this tool on the [git-sim project page](https://initialcommit.com/tools/git-sim). diff --git a/git_sim/__init__.py b/git_sim/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/git_sim/__main__.py b/git_sim/__main__.py deleted file mode 100644 index fbaa3c1..0000000 --- a/git_sim/__main__.py +++ /dev/null @@ -1,356 +0,0 @@ -import argparse -import datetime -import os -import pathlib -import subprocess -import sys -import time -from argparse import Namespace -from typing import Type - -import cv2 -import git -from manim import WHITE, config -from manim.utils.file_ops import open_file as open_media_file - -from git_sim.git_sim_add import GitSimAdd -from git_sim.git_sim_base_command import GitSimBaseCommand -from git_sim.git_sim_branch import GitSimBranch -from git_sim.git_sim_cherrypick import GitSimCherryPick -from git_sim.git_sim_commit import GitSimCommit -from git_sim.git_sim_log import GitSimLog -from git_sim.git_sim_merge import GitSimMerge -from git_sim.git_sim_rebase import GitSimRebase -from git_sim.git_sim_reset import GitSimReset -from git_sim.git_sim_restore import GitSimRestore -from git_sim.git_sim_revert import GitSimRevert -from git_sim.git_sim_stash import GitSimStash -from git_sim.git_sim_status import GitSimStatus -from git_sim.git_sim_tag import GitSimTag - - -def get_scene_for_command(args: Namespace) -> Type[GitSimBaseCommand]: - - if args.subcommand == "log": - return GitSimLog - elif args.subcommand == "status": - return GitSimStatus - elif args.subcommand == "add": - return GitSimAdd - elif args.subcommand == "restore": - return GitSimRestore - elif args.subcommand == "commit": - return GitSimCommit - elif args.subcommand == "stash": - return GitSimStash - elif args.subcommand == "branch": - return GitSimBranch - elif args.subcommand == "tag": - return GitSimTag - elif args.subcommand == "reset": - return GitSimReset - elif args.subcommand == "revert": - return GitSimRevert - elif args.subcommand == "merge": - return GitSimMerge - elif args.subcommand == "rebase": - return GitSimRebase - elif args.subcommand == "cherry-pick": - return GitSimCherryPick - - raise NotImplementedError(f"command '{args.subcommand}' is not yet implemented.") - - -def main(): - parser = argparse.ArgumentParser( - "git-sim", formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - parser.add_argument( - "--title", - help="Custom title to display at the beginning of the animation", - type=str, - default="Git Sim, by initialcommit.com", - ) - parser.add_argument( - "--logo", - help="The path to a custom logo to use in the animation intro/outro", - type=str, - default=os.path.join(str(pathlib.Path(__file__).parent.resolve()), "logo.png"), - ) - parser.add_argument( - "--outro-top-text", - help="Custom text to display above the logo during the outro", - type=str, - default="Thanks for using Initial Commit!", - ) - parser.add_argument( - "--outro-bottom-text", - help="Custom text to display below the logo during the outro", - type=str, - default="Learn more at initialcommit.com", - ) - parser.add_argument( - "--show-intro", - help="Add an intro sequence with custom logo and title", - action="store_true", - ) - parser.add_argument( - "--show-outro", - help="Add an outro sequence with custom logo and text", - action="store_true", - ) - parser.add_argument( - "--media-dir", - help="The path to output the animation data and video file", - type=str, - default=".", - ) - parser.add_argument( - "--low-quality", - help="Render output video in low quality, useful for faster testing", - action="store_true", - ) - parser.add_argument( - "--light-mode", - help="Enable light-mode with white background", - action="store_true", - ) - parser.add_argument( - "--speed", - help="A multiple of the standard 1x animation speed (ex: 2 = twice as fast, 0.5 = half as fast)", - type=float, - default=1.5, - ) - parser.add_argument( - "--animate", - help="Animate the simulation and output as an mp4 video", - action="store_true", - ) - parser.add_argument( - "--max-branches-per-commit", - help="Maximum number of branch labels to display for each commit", - type=int, - default=1, - ) - parser.add_argument( - "--max-tags-per-commit", - help="Maximum number of tags to display for each commit", - type=int, - default=1, - ) - parser.add_argument( - "-d", - "--disable-auto-open", - help="Disable the automatic opening of the image/video file after generation", - action="store_true", - ) - parser.add_argument( - "-r", - "--reverse", - help="Display commit history in the reverse direction", - action="store_true", - ) - parser.add_argument( - "--video-format", - help="Output format for the animation files. Supports mp4 and webm", - type=str, - default="mp4", - ) - - subparsers = parser.add_subparsers(dest="subcommand", help="subcommand help") - - log = subparsers.add_parser("log", help="log -h") - log.add_argument( - "--commits", - help="The number of commits to display in the simulated log output", - type=int, - default=5, - choices=range(1, 13), - ) - - status = subparsers.add_parser("status", help="status -h") - - add = subparsers.add_parser("add", help="add -h") - add.add_argument( - "name", - nargs="+", - help="The names of one or more files to add to Git's staging area", - type=str, - ) - - restore = subparsers.add_parser("restore", help="restore -h") - restore.add_argument( - "name", nargs="+", help="The names of one or more files to restore", type=str - ) - - commit = subparsers.add_parser("commit", help="commit -h") - commit.add_argument( - "-m", - "--message", - help="The commit message of the new commit", - type=str, - default="New commit", - ) - commit.add_argument( - "--amend", - help="Amend the last commit message, must be used with the -m flag", - action="store_true", - ) - - stash = subparsers.add_parser("stash", help="stash -h") - stash.add_argument( - "name", nargs="*", help="The name of the file to stash changes for", type=str - ) - - branch = subparsers.add_parser("branch", help="branch -h") - branch.add_argument("name", help="The name of the new branch", type=str) - - tag = subparsers.add_parser("tag", help="tag -h") - tag.add_argument("name", help="The name of the new tag", type=str) - - reset = subparsers.add_parser("reset", help="reset -h") - reset.add_argument( - "commit", - nargs="?", - help="The ref (branch/tag), or commit ID to simulate reset to", - type=str, - default="HEAD", - ) - reset.add_argument( - "--mode", - help="Either mixed (default), soft, or hard", - type=str, - default="default", - ) - reset.add_argument( - "--soft", - help="Simulate a soft reset, shortcut for --mode=soft", - action="store_true", - ) - reset.add_argument( - "--mixed", - help="Simulate a mixed reset, shortcut for --mode=mixed", - action="store_true", - ) - reset.add_argument( - "--hard", - help="Simulate a soft reset, shortcut for --mode=hard", - action="store_true", - ) - - revert = subparsers.add_parser("revert", help="revert -h") - revert.add_argument( - "commit", - nargs="?", - help="The ref (branch/tag), or commit ID to simulate revert", - type=str, - default="HEAD", - ) - - merge = subparsers.add_parser("merge", help="merge -h") - merge.add_argument( - "branch", - nargs=1, - type=str, - help="The name of the branch to merge into the active checked-out branch", - ) - merge.add_argument( - "--no-ff", - help="Simulate creation of a merge commit in all cases, even when the merge could instead be resolved as a fast-forward", - action="store_true", - ) - - rebase = subparsers.add_parser("rebase", help="rebase -h") - rebase.add_argument( - "branch", - nargs=1, - type=str, - help="The branch to simulate rebasing the checked-out commit onto", - ) - - cherrypick = subparsers.add_parser("cherry-pick", help="cherry-pick -h") - cherrypick.add_argument( - "commit", - nargs=1, - type=str, - help="The ref (branch/tag), or commit ID to simulate cherry-pick onto active branch", - ) - - if len(sys.argv) == 1: - parser.print_help() - sys.exit(1) - - args = parser.parse_args() - - if sys.platform == "linux" or sys.platform == "darwin": - repo_name = git.Repo(search_parent_directories=True).working_tree_dir.split( - "/" - )[-1] - elif sys.platform == "win32": - repo_name = git.Repo(search_parent_directories=True).working_tree_dir.split( - "\\" - )[-1] - - config.media_dir = os.path.join(os.path.expanduser(args.media_dir), "git-sim_media") - config.verbosity = "ERROR" - - # If the env variable is set and no argument provided, use the env variable value - if os.getenv("git_sim_media_dir") and args.media_dir == ".": - config.media_dir = os.path.join( - os.path.expanduser(os.getenv("git_sim_media_dir")), - "git-sim_media", - repo_name, - ) - - if args.low_quality: - config.quality = "low_quality" - - if args.light_mode: - config.background_color = WHITE - - scene_class = get_scene_for_command(args=args) - scene = scene_class(args=args) - scene.render() - - if args.video_format == "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...") - # Start ffmpeg conversion - p = subprocess.Popen(cmd, shell=True) - p.wait() - # if the conversion is successful, delete the .mp4 - if os.path.exists(webm_file_path): - os.remove(scene.renderer.file_writer.movie_file_path) - scene.renderer.file_writer.movie_file_path = webm_file_path - - if not args.animate: - video = cv2.VideoCapture(str(scene.renderer.file_writer.movie_file_path)) - success, image = video.read() - if success: - t = datetime.datetime.fromtimestamp(time.time()).strftime( - "%m-%d-%y_%H-%M-%S" - ) - image_file_name = "git-sim-" + args.subcommand + "_" + t + ".jpg" - image_file_path = os.path.join( - os.path.join(config.media_dir, "images"), image_file_name - ) - cv2.imwrite(image_file_path, image) - print("Output image location:", image_file_path) - else: - print("Output video location:", scene.renderer.file_writer.movie_file_path) - - if not args.disable_auto_open: - try: - if not args.animate: - open_media_file(image_file_path) - else: - open_media_file(scene.renderer.file_writer.movie_file_path) - except FileNotFoundError: - print( - "Error automatically opening media, please manually open the image or video file to view." - ) - - -if __name__ == "__main__": - main() diff --git a/git_sim/git_sim_base_command.py b/git_sim/git_sim_base_command.py deleted file mode 100644 index 22bbda1..0000000 --- a/git_sim/git_sim_base_command.py +++ /dev/null @@ -1,977 +0,0 @@ -import platform -import sys -from argparse import Namespace - -import git -import manim as m -import numpy - - -class GitSimBaseCommand(m.MovingCameraScene): - def __init__(self, args: Namespace): - - super().__init__() - self.init_repo() - - self.args = args - self.fontColor = m.BLACK if self.args.light_mode else m.WHITE - self.drawnCommits = {} - self.drawnRefs = {} - self.drawnCommitIds = {} - self.commits = [] - self.zoomOuts = 0 - self.toFadeOut = m.Group() - self.trimmed = False - self.prevRef = None - self.topref = None - self.maxrefs = None - self.i = 0 - self.numCommits = 5 - self.defaultNumCommits = 5 - self.selected_branches = [] - self.hide_first_tag = False - self.stop = False - self.zone_title_offset = 2.6 if platform.system() == "Windows" else 2.6 - self.allow_no_commits = False - - self.logo = m.ImageMobject(self.args.logo) - self.logo.width = 3 - - def init_repo(self): - try: - self.repo = git.Repo(search_parent_directories=True) - except git.exc.InvalidGitRepositoryError: - print("git-sim error: No Git repository found at current path.") - sys.exit(1) - - def execute(self): - print("Simulating: git " + self.args.subcommand) - self.show_intro() - self.get_commits() - self.fadeout() - self.show_outro() - - def get_commits(self, start="HEAD"): - if not self.numCommits: - if self.allow_no_commits: - self.numCommits = self.defaultNumCommits - self.commits = ["dark"] * 5 - self.zone_title_offset = 2 - return - else: - print("git-sim error: No commits in current Git repository.") - sys.exit(1) - - try: - self.commits = ( - list(self.repo.iter_commits(start)) - if self.numCommits == 1 - else list( - self.repo.iter_commits( - start + "~" + str(self.numCommits) + "..." + start - ) - ) - ) - if len(self.commits) < self.defaultNumCommits: - self.commits = list(self.repo.iter_commits(start)) - while len(self.commits) < self.defaultNumCommits: - self.commits.append(self.create_dark_commit()) - self.numCommits = self.defaultNumCommits - - except git.exc.GitCommandError: - self.numCommits -= 1 - self.get_commits(start=start) - - def parse_commits( - self, commit, prevCircle=None, shift=numpy.array([0.0, 0.0, 0.0]), dots=False - ): - if self.stop: - return - if self.i < self.numCommits and commit in self.commits: - commitId, circle, arrow, hide_refs = self.draw_commit( - commit, prevCircle, shift, dots - ) - - if commit != "dark": - if not hide_refs and not self.stop: - self.draw_head(commit, commitId) - self.draw_branch(commit) - self.draw_tag(commit) - self.draw_arrow(prevCircle, arrow) - if self.stop: - return - if self.i == 0 and len(self.drawnRefs) < 2: - self.draw_dark_ref() - - if self.i < len(self.commits) - 1: - self.i += 1 - self.parse_commits(self.commits[self.i], circle, dots=True) - else: - self.i = 0 - - def show_intro(self): - if self.args.animate and self.args.show_intro: - self.add(self.logo) - - initialCommitText = m.Text( - self.args.title, - font="Monospace", - font_size=36, - color=self.fontColor, - ).to_edge(m.UP, buff=1) - self.add(initialCommitText) - self.wait(2) - self.play(m.FadeOut(initialCommitText)) - self.play( - self.logo.animate.scale(0.25) - .to_edge(m.UP, buff=0) - .to_edge(m.RIGHT, buff=0) - ) - - self.camera.frame.save_state() - self.play(m.FadeOut(self.logo)) - - else: - self.logo.scale(0.25).to_edge(m.UP, buff=0).to_edge(m.RIGHT, buff=0) - self.camera.frame.save_state() - - def show_outro(self): - if self.args.animate and self.args.show_outro: - - self.play(m.Restore(self.camera.frame)) - - self.play(self.logo.animate.scale(4).set_x(0).set_y(0)) - - outroTopText = m.Text( - self.args.outro_top_text, - font="Monospace", - font_size=36, - color=self.fontColor, - ).to_edge(m.UP, buff=1) - self.play(m.AddTextLetterByLetter(outroTopText)) - - outroBottomText = m.Text( - self.args.outro_bottom_text, - font="Monospace", - font_size=36, - color=self.fontColor, - ).to_edge(m.DOWN, buff=1) - self.play(m.AddTextLetterByLetter(outroBottomText)) - - self.wait(3) - - def fadeout(self): - if self.args.animate: - self.wait(3) - self.play(m.FadeOut(self.toFadeOut), run_time=1 / self.args.speed) - else: - self.wait(0.1) - - def get_centers(self): - centers = [] - for commit in self.drawnCommits.values(): - centers.append(commit.get_center()) - return centers - - def draw_commit( - self, commit, prevCircle, shift=numpy.array([0.0, 0.0, 0.0]), dots=False - ): - if commit == "dark": - commitFill = m.WHITE if self.args.light_mode else m.BLACK - elif len(commit.parents) <= 1: - commitFill = m.RED - else: - commitFill = m.GRAY - - circle = m.Circle( - stroke_color=commitFill, fill_color=commitFill, fill_opacity=0.25 - ) - circle.height = 1 - - if shift.any(): - circle.shift(shift) - - if prevCircle: - circle.next_to( - prevCircle, m.RIGHT if self.args.reverse else m.LEFT, buff=1.5 - ) - - start = ( - prevCircle.get_center() - if prevCircle - else (m.LEFT if self.args.reverse else m.RIGHT) - ) - end = circle.get_center() - - if commit == "dark": - arrow = m.Arrow( - start, end, color=m.WHITE if self.args.light_mode else m.BLACK - ) - elif commit.hexsha in self.drawnCommits: - end = self.drawnCommits[commit.hexsha].get_center() - arrow = m.Arrow(start, end, color=self.fontColor) - self.stop = True - else: - 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) - - commitId, commitMessage, commit, hide_refs = self.build_commit_id_and_message( - commit, dots - ) - commitId.next_to(circle, m.UP) - - if commit != "dark": - self.drawnCommitIds[commit.hexsha] = commitId - - message = m.Text( - "\n".join( - commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) - )[:100], - font="Monospace", - font_size=14, - color=self.fontColor, - ).next_to(circle, m.DOWN) - - if self.args.animate and commit != "dark" and not self.stop: - self.play( - self.camera.frame.animate.move_to(circle.get_center()), - m.Create(circle), - m.AddTextLetterByLetter(commitId), - m.AddTextLetterByLetter(message), - run_time=1 / self.args.speed, - ) - elif not self.stop: - self.add(circle, commitId, message) - else: - return commitId, circle, arrow, hide_refs - - if commit != "dark": - self.drawnCommits[commit.hexsha] = circle - - self.toFadeOut.add(circle, commitId, message) - self.prevRef = commitId - - return commitId, circle, arrow, hide_refs - - def build_commit_id_and_message(self, commit, dots=False): - hide_refs = False - if commit == "dark": - commitId = m.Text("", font="Monospace", font_size=20, color=self.fontColor) - commitMessage = "" - elif ( - dots - and self.commits[-1] != "dark" - and commit.hexsha == self.commits[-1].hexsha - ): - commitId = m.Text( - "...", font="Monospace", font_size=20, color=self.fontColor - ) - commitMessage = "..." - else: - commitId = m.Text( - commit.hexsha[0:6], - font="Monospace", - font_size=20, - color=self.fontColor, - ) - commitMessage = commit.message.split("\n")[0][:40].replace("\n", " ") - return commitId, commitMessage, commit, hide_refs - - def draw_head(self, commit, commitId): - if commit.hexsha == self.repo.head.commit.hexsha: - headbox = m.Rectangle(color=m.BLUE, fill_color=m.BLUE, fill_opacity=0.25) - headbox.width = 1 - headbox.height = 0.4 - headbox.next_to(commitId, m.UP) - headText = m.Text( - "HEAD", font="Monospace", font_size=20, color=self.fontColor - ).move_to(headbox.get_center()) - - head = m.VGroup(headbox, headText) - - if self.args.animate: - self.play(m.Create(head), run_time=1 / self.args.speed) - else: - self.add(head) - - self.toFadeOut.add(head) - self.drawnRefs["HEAD"] = head - self.prevRef = head - - if self.i == 0: - self.topref = self.prevRef - - def draw_branch(self, commit): - x = 0 - - remote_tracking_branches = self.get_remote_tracking_branches() - - branches = [branch.name for branch in self.repo.heads] + list( - remote_tracking_branches.keys() - ) - - for selected_branch in self.selected_branches: - branches.insert(0, branches.pop(branches.index(selected_branch))) - - for branch in branches: - # Use forward slash to check if branch is local or remote tracking - # and draw the branch label if its hexsha matches the current commit - if ( - not self.is_remote_tracking_branch(branch) # local branch - and commit.hexsha == self.repo.heads[branch].commit.hexsha - ) or ( - self.is_remote_tracking_branch(branch) # remote tracking branch - and commit.hexsha == remote_tracking_branches[branch] - ): - branchText = m.Text( - branch, font="Monospace", font_size=20, color=self.fontColor - ) - branchRec = m.Rectangle( - color=m.GREEN, - fill_color=m.GREEN, - fill_opacity=0.25, - height=0.4, - width=branchText.width + 0.25, - ) - - branchRec.next_to(self.prevRef, m.UP) - branchText.move_to(branchRec.get_center()) - - fullbranch = m.VGroup(branchRec, branchText) - - self.prevRef = fullbranch - - if self.args.animate: - self.play(m.Create(fullbranch), run_time=1 / self.args.speed) - else: - self.add(fullbranch) - - self.toFadeOut.add(branchRec, branchText) - self.drawnRefs[branch] = fullbranch - - if self.i == 0: - self.topref = self.prevRef - - x += 1 - if x >= self.args.max_branches_per_commit: - return - - def draw_tag(self, commit): - x = 0 - - if self.hide_first_tag and self.i == 0: - return - - for tag in self.repo.tags: - - try: - if commit.hexsha == tag.commit.hexsha: - tagText = m.Text( - tag.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.prevRef, m.UP) - tagText.move_to(tagRec.get_center()) - - self.prevRef = tagRec - - if self.args.animate: - self.play( - m.Create(tagRec), - m.Create(tagText), - run_time=1 / self.args.speed, - ) - else: - self.add(tagRec, tagText) - - self.toFadeOut.add(tagRec, tagText) - - if self.i == 0: - self.topref = self.prevRef - - x += 1 - if x >= self.args.max_tags_per_commit: - return - except ValueError: - pass - - def draw_arrow(self, prevCircle, arrow): - if prevCircle: - if self.args.animate: - self.play(m.Create(arrow), run_time=1 / self.args.speed) - else: - self.add(arrow) - - self.toFadeOut.add(arrow) - - def recenter_frame(self): - if self.args.animate: - self.play( - self.camera.frame.animate.move_to(self.toFadeOut.get_center()), - run_time=1 / self.args.speed, - ) - else: - self.camera.frame.move_to(self.toFadeOut.get_center()) - - def scale_frame(self): - if self.args.animate: - self.play( - self.camera.frame.animate.scale_to_fit_width( - self.toFadeOut.get_width() * 1.1 - ), - run_time=1 / self.args.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 - ), - run_time=1 / self.args.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(): - self.camera.frame.scale_to_fit_height( - self.toFadeOut.get_height() * 1.25 - ) - - def vsplit_frame(self): - if self.args.animate: - self.play( - self.camera.frame.animate.scale_to_fit_height( - self.camera.frame.get_height() * 2 - ) - ) - else: - self.camera.frame.scale_to_fit_height(self.camera.frame.get_height() * 2) - - try: - if self.args.animate: - self.play( - self.toFadeOut.animate.align_to(self.camera.frame, m.UP).shift( - m.DOWN * 0.75 - ) - ) - else: - self.toFadeOut.align_to(self.camera.frame, m.UP).shift(m.DOWN * 0.75) - except ValueError: - pass - - def setup_and_draw_zones( - self, - first_column_name="Untracked files", - second_column_name="Working directory modifications", - third_column_name="Staging area", - reverse=False, - ): - horizontal = m.Line( - ( - self.camera.frame.get_left()[0], - self.camera.frame.get_center()[1], - 0, - ), - ( - self.camera.frame.get_right()[0], - self.camera.frame.get_center()[1], - 0, - ), - color=self.fontColor, - ).shift(m.UP * 2.5) - horizontal2 = m.Line( - ( - self.camera.frame.get_left()[0], - self.camera.frame.get_center()[1], - 0, - ), - ( - self.camera.frame.get_right()[0], - self.camera.frame.get_center()[1], - 0, - ), - color=self.fontColor, - ).shift(m.UP * 1.5) - vert1 = m.DashedLine( - ( - self.camera.frame.get_left()[0], - self.camera.frame.get_bottom()[1], - 0, - ), - (self.camera.frame.get_left()[0], horizontal.get_start()[1], 0), - dash_length=0.2, - color=self.fontColor, - ).shift(m.RIGHT * 6.5) - vert2 = m.DashedLine( - ( - self.camera.frame.get_right()[0], - self.camera.frame.get_bottom()[1], - 0, - ), - (self.camera.frame.get_right()[0], horizontal.get_start()[1], 0), - dash_length=0.2, - color=self.fontColor, - ).shift(m.LEFT * 6.5) - - if reverse: - first_column_name = "Staging area" - third_column_name = "Deleted changes" - - firstColumnTitle = ( - m.Text( - first_column_name, - font="Monospace", - font_size=28, - color=self.fontColor, - ) - .align_to(self.camera.frame, m.LEFT) - .shift(m.RIGHT * 0.65) - .shift(m.UP * self.zone_title_offset) - ) - secondColumnTitle = ( - m.Text( - second_column_name, - font="Monospace", - font_size=28, - color=self.fontColor, - ) - .move_to(self.camera.frame.get_center()) - .align_to(firstColumnTitle, m.UP) - ) - thirdColumnTitle = ( - m.Text( - third_column_name, - font="Monospace", - font_size=28, - color=self.fontColor, - ) - .align_to(self.camera.frame, m.RIGHT) - .shift(m.LEFT * 1.65) - .align_to(firstColumnTitle, m.UP) - ) - - self.toFadeOut.add( - horizontal, - horizontal2, - vert1, - vert2, - firstColumnTitle, - secondColumnTitle, - thirdColumnTitle, - ) - - if self.args.animate: - self.play( - m.Create(horizontal), - m.Create(horizontal2), - m.Create(vert1), - m.Create(vert2), - m.AddTextLetterByLetter(firstColumnTitle), - m.AddTextLetterByLetter(secondColumnTitle), - m.AddTextLetterByLetter(thirdColumnTitle), - ) - else: - self.add( - horizontal, - horizontal2, - vert1, - vert2, - firstColumnTitle, - secondColumnTitle, - thirdColumnTitle, - ) - - firstColumnFileNames = set() - secondColumnFileNames = set() - thirdColumnFileNames = set() - - firstColumnArrowMap = {} - secondColumnArrowMap = {} - - self.populate_zones( - firstColumnFileNames, - secondColumnFileNames, - thirdColumnFileNames, - firstColumnArrowMap, - secondColumnArrowMap, - ) - - firstColumnFiles = m.VGroup() - secondColumnFiles = m.VGroup() - thirdColumnFiles = m.VGroup() - - firstColumnFilesDict = {} - secondColumnFilesDict = {} - thirdColumnFilesDict = {} - - for i, f in enumerate(firstColumnFileNames): - text = ( - m.Text( - self.trim_path(f), - font="Monospace", - 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="Monospace", - 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.Text( - self.trim_path(f), - font="Monospace", - 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 - - if len(firstColumnFiles): - if self.args.animate: - self.play(*[m.AddTextLetterByLetter(d) for d in firstColumnFiles]) - else: - self.add(*[d for d in firstColumnFiles]) - - if len(secondColumnFiles): - if self.args.animate: - self.play(*[m.AddTextLetterByLetter(w) for w in secondColumnFiles]) - else: - self.add(*[w for w in secondColumnFiles]) - - if len(thirdColumnFiles): - if self.args.animate: - self.play(*[m.AddTextLetterByLetter(s) for s in thirdColumnFiles]) - else: - self.add(*[s for s in thirdColumnFiles]) - - for filename in firstColumnArrowMap: - if reverse: - firstColumnArrowMap[filename].put_start_and_end_on( - ( - firstColumnFilesDict[filename].get_right()[0] + 0.25, - firstColumnFilesDict[filename].get_right()[1], - 0, - ), - ( - secondColumnFilesDict[filename].get_left()[0] - 0.25, - secondColumnFilesDict[filename].get_left()[1], - 0, - ), - ) - else: - firstColumnArrowMap[filename].put_start_and_end_on( - ( - firstColumnFilesDict[filename].get_right()[0] + 0.25, - firstColumnFilesDict[filename].get_right()[1], - 0, - ), - ( - thirdColumnFilesDict[filename].get_left()[0] - 0.25, - thirdColumnFilesDict[filename].get_left()[1], - 0, - ), - ) - if self.args.animate: - self.play(m.Create(firstColumnArrowMap[filename])) - else: - self.add(firstColumnArrowMap[filename]) - self.toFadeOut.add(firstColumnArrowMap[filename]) - - for filename in secondColumnArrowMap: - secondColumnArrowMap[filename].put_start_and_end_on( - ( - secondColumnFilesDict[filename].get_right()[0] + 0.25, - secondColumnFilesDict[filename].get_right()[1], - 0, - ), - ( - thirdColumnFilesDict[filename].get_left()[0] - 0.25, - thirdColumnFilesDict[filename].get_left()[1], - 0, - ), - ) - if self.args.animate: - self.play(m.Create(secondColumnArrowMap[filename])) - else: - self.add(secondColumnArrowMap[filename]) - self.toFadeOut.add(secondColumnArrowMap[filename]) - - self.toFadeOut.add(firstColumnFiles, secondColumnFiles, thirdColumnFiles) - - def populate_zones( - self, - firstColumnFileNames, - secondColumnFileNames, - thirdColumnFileNames, - firstColumnArrowMap={}, - secondColumnArrowMap={}, - ): - - for x in self.repo.index.diff(None): - if "git-sim_media" not in x.a_path: - secondColumnFileNames.add(x.a_path) - - try: - for y in self.repo.index.diff("HEAD"): - if "git-sim_media" not in y.a_path: - thirdColumnFileNames.add(y.a_path) - except git.exc.BadName: - for (y, _stage), entry in self.repo.index.entries.items(): - if "git-sim_media" not in y: - thirdColumnFileNames.add(y) - - for z in self.repo.untracked_files: - if "git-sim_media" not in z: - firstColumnFileNames.add(z) - - def center_frame_on_commit(self, commit): - if self.args.animate: - self.play( - self.camera.frame.animate.move_to( - self.drawnCommits[commit.hexsha].get_center() - ) - ) - else: - 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 self.args.animate: - self.play( - self.drawnRefs["HEAD"].animate.move_to( - ( - self.drawnCommits[hexsha].get_center()[0] + shift[0], - self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], - 0, - ) - ), - self.drawnRefs[self.repo.active_branch.name].animate.move_to( - ( - self.drawnCommits[hexsha].get_center()[0] + shift[0], - self.drawnCommits[hexsha].get_center()[1] + 2 + shift[1], - 0, - ) - ), - ) - else: - self.drawnRefs["HEAD"].move_to( - ( - self.drawnCommits[hexsha].get_center()[0] + shift[0], - self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], - 0, - ) - ) - self.drawnRefs[self.repo.active_branch.name].move_to( - ( - self.drawnCommits[hexsha].get_center()[0] + shift[0], - self.drawnCommits[hexsha].get_center()[1] + 2 + shift[1], - 0, - ) - ) - - def translate_frame(self, shift): - if self.args.animate: - self.play(self.camera.frame.animate.shift(shift)) - else: - self.camera.frame.shift(shift) - - def setup_and_draw_parent( - self, - child, - commitMessage="New commit", - shift=numpy.array([0.0, 0.0, 0.0]), - 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 self.args.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) - - commitId = m.Text( - "abcdef", font="Monospace", font_size=20, color=self.fontColor - ).next_to(circle, m.UP) - self.toFadeOut.add(commitId) - - commitMessage = commitMessage.split("\n")[0][:40].replace("\n", " ") - message = m.Text( - "\n".join( - commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) - )[:100], - font="Monospace", - font_size=14, - color=self.fontColor, - ).next_to(circle, m.DOWN) - self.toFadeOut.add(message) - - if self.args.animate: - self.play( - self.camera.frame.animate.move_to(circle.get_center()), - m.Create(circle), - m.AddTextLetterByLetter(commitId), - m.AddTextLetterByLetter(message), - run_time=1 / self.args.speed, - ) - else: - self.camera.frame.move_to(circle.get_center()) - self.add(circle, commitId, message) - - self.drawnCommits["abcdef"] = circle - self.toFadeOut.add(circle) - - if draw_arrow: - if self.args.animate: - self.play(m.Create(arrow), run_time=1 / self.args.speed) - else: - self.add(arrow) - self.toFadeOut.add(arrow) - - return commitId - - def draw_arrow_between_commits(self, startsha, endsha): - start = self.drawnCommits[startsha].get_center() - end = self.drawnCommits[endsha].get_center() - - arrow = DottedLine(start, end, color=self.fontColor).add_tip() - length = numpy.linalg.norm(start - end) - 1.65 - arrow.set_length(length) - self.draw_arrow(True, arrow) - - def create_dark_commit(self): - return "dark" - - def get_nondark_commits(self): - nondark_commits = [] - for commit in self.commits: - if commit != "dark": - nondark_commits.append(commit) - return nondark_commits - - def draw_ref(self, commit, top, text="HEAD", color=m.BLUE): - refText = m.Text(text, font="Monospace", font_size=20, color=self.fontColor) - refbox = m.Rectangle( - color=color, - fill_color=color, - fill_opacity=0.25, - height=0.4, - width=refText.width + 0.25, - ) - refbox.next_to(top, m.UP) - refText.move_to(refbox.get_center()) - - ref = m.VGroup(refbox, refText) - - if self.args.animate: - self.play(m.Create(ref), run_time=1 / self.args.speed) - else: - self.add(ref) - - self.toFadeOut.add(ref) - self.drawnRefs[text] = ref - self.prevRef = ref - - if self.i == 0: - self.topref = self.prevRef - - def draw_dark_ref(self): - refRec = m.Rectangle( - color=m.WHITE if self.args.light_mode else m.BLACK, - fill_color=m.WHITE if self.args.light_mode else m.BLACK, - height=0.4, - width=1, - ) - refRec.next_to(self.prevRef, m.UP) - self.add(refRec) - self.toFadeOut.add(refRec) - self.prevRef = refRec - - def trim_path(self, path): - return (path[:5] + "..." + path[-15:]) if len(path) > 20 else path - - def get_remote_tracking_branches(self): - 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 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 - - -class DottedLine(m.Line): - def __init__(self, *args, dot_spacing=0.4, dot_kwargs={}, **kwargs): - m.Line.__init__(self, *args, **kwargs) - n_dots = int(self.get_length() / dot_spacing) + 1 - dot_spacing = self.get_length() / (n_dots - 1) - unit_vector = self.get_unit_vector() - start = self.start - - self.dot_points = [start + unit_vector * dot_spacing * x for x in range(n_dots)] - self.dots = [m.Dot(point, **dot_kwargs) for point in self.dot_points] - - self.clear_points() - - self.add(*self.dots) - - self.get_start = lambda: self.dot_points[0] - self.get_end = lambda: self.dot_points[-1] - - def get_first_handle(self): - return self.dot_points[-1] - - def get_last_handle(self): - return self.dot_points[-2] diff --git a/git_sim/git_sim_cherrypick.py b/git_sim/git_sim_cherrypick.py deleted file mode 100644 index 90023e2..0000000 --- a/git_sim/git_sim_cherrypick.py +++ /dev/null @@ -1,60 +0,0 @@ -import sys -from argparse import Namespace - -import git -import manim as m - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimCherryPick(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - - try: - git.repo.fun.rev_parse(self.repo, self.args.commit[0]) - except git.exc.BadName: - print( - "git-sim error: '" - + self.args.commit[0] - + "' is not a valid Git ref or identifier." - ) - sys.exit(1) - - if self.args.commit[0] in [branch.name for branch in self.repo.heads]: - self.selected_branches.append(self.args.commit[0]) - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - def construct(self): - print("Simulating: git " + self.args.subcommand + " " + self.args.commit[0]) - - if self.repo.active_branch.name in self.repo.git.branch( - "--contains", self.args.commit[0] - ): - print( - "git-sim error: Commit '" - + self.args.commit[0] - + "' is already included in the history of active branch '" - + self.repo.active_branch.name - + "'." - ) - sys.exit(1) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - self.orig_commits = self.commits - self.get_commits(start=self.args.commit[0]) - self.parse_commits(self.commits[0], shift=4 * m.DOWN) - self.center_frame_on_commit(self.orig_commits[0]) - self.setup_and_draw_parent(self.orig_commits[0], self.commits[0].message) - self.draw_arrow_between_commits(self.commits[0].hexsha, "abcdef") - self.recenter_frame() - self.scale_frame() - self.reset_head_branch("abcdef") - self.fadeout() - self.show_outro() diff --git a/git_sim/git_sim_commit.py b/git_sim/git_sim_commit.py deleted file mode 100644 index aec397a..0000000 --- a/git_sim/git_sim_commit.py +++ /dev/null @@ -1,99 +0,0 @@ -import sys -from argparse import Namespace - -import git -import manim as m - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimCommit(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - self.maxrefs = 2 - self.defaultNumCommits = 4 if not self.args.amend else 5 - self.numCommits = 4 if not self.args.amend else 5 - self.hide_first_tag = True - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - if self.args.amend and self.args.message == "New commit": - print( - "git-sim error: The --amend flag must be used with the -m flag to specify the amended commit message." - ) - sys.exit(1) - - def construct(self): - print( - "Simulating: git " - + self.args.subcommand - + (" --amend" if self.args.amend else "") - + ' -m "' - + self.args.message - + '"' - ) - - self.show_intro() - self.get_commits() - - if self.args.amend: - tree = self.repo.tree() - amended = git.Commit.create_from_tree( - self.repo, - tree, - self.args.message, - ) - self.commits[0] = amended - - self.parse_commits(self.commits[self.i]) - self.center_frame_on_commit(self.commits[0]) - - if not self.args.amend: - self.setup_and_draw_parent(self.commits[0], self.args.message) - else: - self.draw_ref(self.commits[0], self.drawnCommitIds[amended.hexsha]) - self.draw_ref( - self.commits[0], - self.drawnRefs["HEAD"], - text=self.repo.active_branch.name, - color=m.GREEN, - ) - - self.recenter_frame() - self.scale_frame() - - if not self.args.amend: - self.reset_head_branch("abcdef") - self.vsplit_frame() - self.setup_and_draw_zones( - first_column_name="Working directory", - second_column_name="Staging area", - third_column_name="New commit", - ) - - self.fadeout() - self.show_outro() - - def populate_zones( - self, - firstColumnFileNames, - secondColumnFileNames, - thirdColumnFileNames, - firstColumnArrowMap, - secondColumnArrowMap, - ): - - for x in self.repo.index.diff(None): - 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 - ) diff --git a/git_sim/git_sim_log.py b/git_sim/git_sim_log.py deleted file mode 100644 index e0fc1f2..0000000 --- a/git_sim/git_sim_log.py +++ /dev/null @@ -1,23 +0,0 @@ -from argparse import Namespace - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimLog(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - def construct(self): - print("Simulating: git " + self.args.subcommand) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - self.recenter_frame() - self.scale_frame() - self.fadeout() - self.show_outro() diff --git a/git_sim/git_sim_merge.py b/git_sim/git_sim_merge.py deleted file mode 100644 index 914a9fe..0000000 --- a/git_sim/git_sim_merge.py +++ /dev/null @@ -1,115 +0,0 @@ -import sys -from argparse import Namespace - -import git -import manim as m -import numpy - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimMerge(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - - try: - git.repo.fun.rev_parse(self.repo, self.args.branch[0]) - except git.exc.BadName: - print( - "git-sim error: '" - + self.args.branch[0] - + "' is not a valid Git ref or identifier." - ) - sys.exit(1) - - self.ff = False - self.maxrefs = 2 - if self.args.branch[0] in [branch.name for branch in self.repo.heads]: - self.selected_branches.append(self.args.branch[0]) - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - def construct(self): - print("Simulating: git " + self.args.subcommand + " " + self.args.branch[0]) - - if self.repo.active_branch.name in self.repo.git.branch( - "--contains", self.args.branch[0] - ): - print( - "git-sim error: Branch '" - + self.args.branch[0] - + "' is already included in the history of active branch '" - + self.repo.active_branch.name - + "'." - ) - sys.exit(1) - - self.show_intro() - self.get_commits() - self.orig_commits = self.commits - self.get_commits(start=self.args.branch[0]) - - # Use forward slash to determine if supplied branch arg is local or remote tracking branch - if not self.is_remote_tracking_branch(self.args.branch[0]): - if self.args.branch[0] in self.repo.git.branch( - "--contains", self.orig_commits[0].hexsha - ): - self.ff = True - else: - if self.args.branch[0] in self.repo.git.branch( - "-r", "--contains", self.orig_commits[0].hexsha - ): - self.ff = True - - if self.ff: - self.get_commits(start=self.args.branch[0]) - self.parse_commits(self.commits[0]) - reset_head_to = self.commits[0].hexsha - shift = numpy.array([0.0, 0.6, 0.0]) - - if self.args.no_ff: - self.center_frame_on_commit(self.commits[0]) - commitId = self.setup_and_draw_parent(self.commits[0], "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: - self.reset_head_branch(reset_head_to, shift=shift) - else: - self.draw_ref( - self.commits[0], commitId if self.args.no_ff else self.topref - ) - self.draw_ref( - self.commits[0], - self.drawnRefs["HEAD"], - text=self.repo.active_branch.name, - color=m.GREEN, - ) - - else: - self.get_commits() - self.parse_commits(self.commits[0]) - self.i = 0 - self.get_commits(start=self.args.branch[0]) - self.parse_commits(self.commits[0], shift=4 * m.DOWN) - self.center_frame_on_commit(self.orig_commits[0]) - self.setup_and_draw_parent( - self.orig_commits[0], - "Merge commit", - shift=2 * m.DOWN, - draw_arrow=False, - color=m.GRAY, - ) - self.draw_arrow_between_commits("abcdef", self.commits[0].hexsha) - self.draw_arrow_between_commits("abcdef", self.orig_commits[0].hexsha) - self.recenter_frame() - self.scale_frame() - self.reset_head_branch("abcdef") - - self.fadeout() - self.show_outro() diff --git a/git_sim/git_sim_restore.py b/git_sim/git_sim_restore.py deleted file mode 100644 index 151d0e0..0000000 --- a/git_sim/git_sim_restore.py +++ /dev/null @@ -1,73 +0,0 @@ -import sys -from argparse import Namespace - -import manim as m - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimRestore(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - self.maxrefs = 2 - self.hide_first_tag = True - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - for name in self.args.name: - if name 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( - "git-sim error: No modified or staged file with name: '" - + name - + "'" - ) - sys.exit() - - def construct(self): - print( - "Simulating: git " + self.args.subcommand + " " + " ".join(self.args.name) - ) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - self.recenter_frame() - self.scale_frame() - self.vsplit_frame() - self.setup_and_draw_zones(reverse=True) - self.fadeout() - self.show_outro() - - def populate_zones( - self, - firstColumnFileNames, - secondColumnFileNames, - thirdColumnFileNames, - firstColumnArrowMap, - secondColumnArrowMap, - ): - - for x in self.repo.index.diff(None): - if "git-sim_media" not in x.a_path: - secondColumnFileNames.add(x.a_path) - for name in self.args.name: - if name == x.a_path: - thirdColumnFileNames.add(x.a_path) - secondColumnArrowMap[x.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor - ) - - for y in self.repo.index.diff("HEAD"): - if "git-sim_media" not in y.a_path: - firstColumnFileNames.add(y.a_path) - for name in self.args.name: - if name == y.a_path: - secondColumnFileNames.add(y.a_path) - firstColumnArrowMap[y.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor - ) diff --git a/git_sim/git_sim_stash.py b/git_sim/git_sim_stash.py deleted file mode 100644 index 445a3d0..0000000 --- a/git_sim/git_sim_stash.py +++ /dev/null @@ -1,82 +0,0 @@ -import sys -from argparse import Namespace - -import git -import manim as m -import numpy - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimStash(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - self.maxrefs = 2 - self.hide_first_tag = True - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - for name in self.args.name: - if name 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( - "git-sim error: No modified or staged file with name: '" - + name - + "'" - ) - sys.exit() - - if not self.args.name: - self.args.name = [x.a_path for x in self.repo.index.diff(None)] + [ - y.a_path for y in self.repo.index.diff("HEAD") - ] - - def construct(self): - print( - "Simulating: git " + self.args.subcommand + " " + " ".join(self.args.name) - ) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - 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="Stashed changes", - ) - self.fadeout() - self.show_outro() - - def populate_zones( - self, - firstColumnFileNames, - secondColumnFileNames, - thirdColumnFileNames, - firstColumnArrowMap, - secondColumnArrowMap, - ): - - for x in self.repo.index.diff(None): - firstColumnFileNames.add(x.a_path) - for name in self.args.name: - if name == x.a_path: - thirdColumnFileNames.add(x.a_path) - firstColumnArrowMap[x.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor - ) - - for y in self.repo.index.diff("HEAD"): - secondColumnFileNames.add(y.a_path) - for name in self.args.name: - if name == y.a_path: - thirdColumnFileNames.add(y.a_path) - secondColumnArrowMap[y.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor - ) diff --git a/git_sim/git_sim_status.py b/git_sim/git_sim_status.py deleted file mode 100644 index 9d857ec..0000000 --- a/git_sim/git_sim_status.py +++ /dev/null @@ -1,29 +0,0 @@ -from argparse import Namespace - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimStatus(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - self.maxrefs = 2 - self.hide_first_tag = True - self.allow_no_commits = True - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - def construct(self): - print("Simulating: git " + self.args.subcommand) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - self.recenter_frame() - self.scale_frame() - self.vsplit_frame() - self.setup_and_draw_zones() - self.fadeout() - self.show_outro() diff --git a/git_sim/git_sim_tag.py b/git_sim/git_sim_tag.py deleted file mode 100644 index af748ac..0000000 --- a/git_sim/git_sim_tag.py +++ /dev/null @@ -1,48 +0,0 @@ -from argparse import Namespace - -import manim as m - -from git_sim.git_sim_base_command import GitSimBaseCommand - - -class GitSimTag(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - - def construct(self): - print("Simulating: git " + self.args.subcommand + " " + self.args.name) - - self.show_intro() - self.get_commits() - self.parse_commits(self.commits[0]) - self.recenter_frame() - self.scale_frame() - - tagText = m.Text( - self.args.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 self.args.animate: - self.play(m.Create(fulltag), run_time=1 / self.args.speed) - else: - self.add(fulltag) - - self.toFadeOut.add(tagRec, tagText) - - self.fadeout() - self.show_outro() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1d821bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "git-sim" +authors = [{ name = "Jacob Stopak", email = "jacob@initialcommit.io" }] +description = "Simulate Git commands on your own repos by generating an image (default) or video visualization depicting the command's behavior." +readme = "README.md" +requires-python = ">=3.7" +keywords = [ + "git", + "sim", + "simulation", + "simulate", + "git-simulate", + "git-simulation", + "git-sim", + "manim", + "animation", + "gitanimation", + "image", + "video", + "dryrun", + "dry-run", +] +license = { text = "GPL-2.0" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "Operating System :: OS Independent", +] +dependencies = [ + "git-dummy", + "gitpython", + "manim", + "opencv-python-headless", + "pydantic_settings", + "typer", + "fonttools", +] +dynamic = ["version"] + +[tool.setuptools.dynamic] +version = { attr = "git_sim.__version__" } + +[project.optional-dependencies] +dev = ["black", "numpy", "pillow", "pytest"] + +[project.scripts] +git-sim = "git_sim.__main__:app" + +[project.urls] +Homepage = "https://initialcommit.com/tools/git-sim" +Source = "https://github.com/initialcommit-com/git-sim" diff --git a/setup.py b/setup.py deleted file mode 100644 index 8a50945..0000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="git-sim", - version="0.2.0", - 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", - ], - 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__:main", - ], - }, - 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/src/git_sim/__main__.py b/src/git_sim/__main__.py new file mode 100644 index 0000000..5def5ea --- /dev/null +++ b/src/git_sim/__main__.py @@ -0,0 +1,279 @@ +import contextlib +import datetime +import os +import pathlib +import sys +import time +from pathlib import Path + +import typer +import manim as m + +from fontTools.ttLib import TTFont + +import git_sim.commands +from git_sim.settings import ( + 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, + animate: bool = typer.Option( + settings.animate, + help="Animate the simulation and output as an mp4 video", + ), + n: int = typer.Option( + settings.n, + "-n", + help="Number of commits to display from each branch head", + ), + auto_open: bool = typer.Option( + settings.auto_open, + "--auto-open", + " /-d", + help="Enable / disable the automatic opening of the image/video file after generation", + ), + img_format: ImgFormat = typer.Option( + settings.img_format, + help="Output format for the image files.", + ), + light_mode: bool = typer.Option( + settings.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", + ), + low_quality: bool = typer.Option( + settings.low_quality, + "--low-quality", + help="Render output video in low quality, useful for faster testing", + ), + max_branches_per_commit: int = typer.Option( + settings.max_branches_per_commit, + help="Maximum number of branch labels to display for each commit", + ), + max_tags_per_commit: int = typer.Option( + settings.max_tags_per_commit, + help="Maximum number of tags to display for each commit", + ), + media_dir: pathlib.Path = typer.Option( + settings.media_dir, + help="The path to output the animation data and video file", + ), + outro_bottom_text: str = typer.Option( + settings.outro_bottom_text, + help="Custom text to display below the logo during the outro", + ), + outro_top_text: str = typer.Option( + settings.outro_top_text, + help="Custom text to display above the logo during the outro", + ), + reverse: bool = typer.Option( + settings.reverse, + "--reverse", + "-r", + help="Display commit history in the reverse direction", + ), + show_intro: bool = typer.Option( + settings.show_intro, + help="Add an intro sequence with custom logo and title", + ), + show_outro: bool = typer.Option( + settings.show_outro, + help="Add an outro sequence with custom logo and text", + ), + speed: float = typer.Option( + settings.speed, + help="A multiple of the standard 1x animation speed (ex: 2 = twice as fast, 0.5 = half as fast)", + ), + title: str = typer.Option( + settings.title, + help="Custom title to display at the beginning of the animation", + ), + video_format: VideoFormat = typer.Option( + settings.video_format.value, + help="Output format for the animation files.", + case_sensitive=False, + ), + stdout: bool = typer.Option( + settings.stdout, + help="Write raw image data to stdout while suppressing all other program output", + ), + output_only_path: bool = typer.Option( + settings.output_only_path, + help="Only output the path to the generated media file to stdout (useful for other programs to ingest)", + ), + quiet: bool = typer.Option( + settings.quiet, + "--quiet", + "-q", + help="Suppress all output except errors", + ), + invert_branches: bool = typer.Option( + settings.invert_branches, + help="Invert positioning of branches by reversing order of multiple parents where applicable", + ), + hide_merged_branches: bool = typer.Option( + settings.hide_merged_branches, + help="Hide commits from merged branches, i.e. only display mainline commits", + ), + all: bool = typer.Option( + 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 + settings.max_tags_per_commit = max_tags_per_commit + settings.media_dir = os.path.join(os.path.expanduser(media_dir), "git-sim_media") + settings.outro_bottom_text = outro_bottom_text + settings.outro_top_text = outro_top_text + settings.reverse = reverse + settings.show_intro = show_intro + settings.show_outro = show_outro + settings.speed = speed + settings.title = title + settings.video_format = video_format + settings.stdout = stdout + settings.output_only_path = output_only_path + settings.quiet = quiet + 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 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) + + config.media_dir = settings.media_dir + config.verbosity = "ERROR" + + if settings.low_quality: + config.quality = "low_quality" + + 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.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__": + app() diff --git a/git_sim/git_sim_add.py b/src/git_sim/add.py similarity index 65% rename from git_sim/git_sim_add.py rename to src/git_sim/add.py index 0dc2e52..cdcb382 100644 --- a/git_sim/git_sim_add.py +++ b/src/git_sim/add.py @@ -1,43 +1,47 @@ import sys -from argparse import Namespace - 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 GitSimAdd(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) - self.maxrefs = 2 +class Add(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 name in self.args.name: - if name not in [x.a_path for x in self.repo.index.diff(None)] + [ + for file in self.files: + if file not in [x.a_path for x in self.repo.index.diff(None)] + [ z for z in self.repo.untracked_files ]: - print("git-sim error: No modified file with name: '" + name + "'") + 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): - print( - "Simulating: git " + self.args.subcommand + " " + " ".join(self.args.name) - ) + 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.get_commits() - self.parse_commits(self.commits[0]) + 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() @@ -46,15 +50,15 @@ def populate_zones( firstColumnFileNames, secondColumnFileNames, thirdColumnFileNames, - firstColumnArrowMap, - secondColumnArrowMap, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, ): - for x in self.repo.index.diff(None): if "git-sim_media" not in x.a_path: secondColumnFileNames.add(x.a_path) - for name in self.args.name: - if name == x.a_path: + for file in self.files: + if file == x.a_path: thirdColumnFileNames.add(x.a_path) secondColumnArrowMap[x.a_path] = m.Arrow( stroke_width=3, color=self.fontColor @@ -71,8 +75,8 @@ def populate_zones( for z in self.repo.untracked_files: if "git-sim_media" not in z: firstColumnFileNames.add(z) - for name in self.args.name: - if name == z: + for file in self.files: + if file == z: thirdColumnFileNames.add(z) firstColumnArrowMap[z] = m.Arrow( stroke_width=3, color=self.fontColor diff --git a/src/git_sim/animations.py b/src/git_sim/animations.py new file mode 100644 index 0000000..027fa6a --- /dev/null +++ b/src/git_sim/animations.py @@ -0,0 +1,90 @@ +import datetime +import inspect +import os +import subprocess +import sys +import time + +import cv2 +import git.repo +from manim import WHITE, Scene +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 == 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...") + # Start ffmpeg conversion + p = subprocess.Popen(cmd, shell=True) + p.wait() + # if the conversion is successful, delete the .mp4 + if os.path.exists(webm_file_path): + os.remove(scene.renderer.file_writer.movie_file_path) + scene.renderer.file_writer.movie_file_path = webm_file_path + + if not settings.animate: + video = cv2.VideoCapture(str(scene.renderer.file_writer.movie_file_path)) + success, image = video.read() + if success: + t = datetime.datetime.fromtimestamp(time.time()).strftime( + "%m-%d-%y_%H-%M-%S" + ) + image_file_name = ( + "git-sim-" + + inspect.stack()[2].function + + "_" + + t + + "." + + settings.img_format + ) + 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 + and not settings.output_only_path + and not settings.quiet + ): + print("Output image location:", image_file_path) + elif ( + not settings.stdout and settings.output_only_path and not settings.quiet + ): + print(image_file_path) + if settings.stdout and not settings.quiet: + sys.stdout.buffer.write(cv2.imencode(".jpg", image)[1].tobytes()) + else: + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print("Output video location:", scene.renderer.file_writer.movie_file_path) + elif not settings.stdout and settings.output_only_path and not settings.quiet: + print(scene.renderer.file_writer.movie_file_path) + + if settings.auto_open and not settings.stdout: + try: + if not settings.animate: + open_file(image_file_path) + else: + open_file(scene.renderer.file_writer.movie_file_path) + except FileNotFoundError: + print( + "Error automatically opening media, please manually open the image or video file to view." + ) diff --git a/git_sim/git_sim_branch.py b/src/git_sim/branch.py similarity index 51% rename from git_sim/git_sim_branch.py rename to src/git_sim/branch.py index 15b9033..9a714d3 100644 --- a/git_sim/git_sim_branch.py +++ b/src/git_sim/branch.py @@ -1,26 +1,27 @@ -from argparse import Namespace - import manim as m from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings -class GitSimBranch(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) +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): - print("Simulating: git " + self.args.subcommand + " " + self.args.name) + 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.get_commits() - self.parse_commits(self.commits[0]) - self.recenter_frame() - self.scale_frame() + self.parse_commits() + self.parse_all() + self.center_frame_on_commit(self.get_commit()) branchText = m.Text( - self.args.name, - font="Monospace", + self.name, + font=self.font, font_size=20, color=self.fontColor, ) @@ -37,13 +38,17 @@ def construct(self): fullbranch = m.VGroup(branchRec, branchText) - if self.args.animate: - self.play(m.Create(fullbranch), run_time=1 / self.args.speed) + if settings.animate: + self.play(m.Create(fullbranch), run_time=1 / settings.speed) else: self.add(fullbranch) self.toFadeOut.add(branchRec, branchText) - self.drawnRefs[self.args.name] = fullbranch + self.drawnRefs[self.name] = fullbranch + self.recenter_frame() + self.scale_frame() + self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() 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/src/git_sim/cherrypick.py b/src/git_sim/cherrypick.py new file mode 100644 index 0000000..5f2d58b --- /dev/null +++ b/src/git_sim/cherrypick.py @@ -0,0 +1,72 @@ +import sys + +import git +import manim as m + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class CherryPick(GitSimBaseCommand): + def __init__(self, commit: str, edit: str): + super().__init__() + self.commit = commit + self.edit = edit + + try: + git.repo.fun.rev_parse(self.repo, self.commit) + except git.exc.BadName: + print( + "git-sim error: '" + + self.commit + + "' is not a valid Git ref or identifier." + ) + sys.exit(1) + + if self.commit in [branch.name for branch in self.repo.heads]: + self.selected_branches.append(self.commit) + + try: + self.selected_branches.append(self.repo.active_branch.name) + 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} {self.cmd}") + + if self.repo.active_branch.name in self.repo.git.branch( + "--contains", self.commit + ): + print( + "git-sim error: Commit '" + + self.commit + + "' 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() + self.parse_commits(head_commit) + cherry_picked_commit = self.get_commit(self.commit) + self.parse_commits(cherry_picked_commit, shift=4 * m.DOWN) + self.parse_all() + self.center_frame_on_commit(head_commit) + self.setup_and_draw_parent( + head_commit, + self.edit if self.edit else cherry_picked_commit.message, + ) + self.draw_arrow_between_commits(cherry_picked_commit.hexsha, "abcdef") + 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() 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/src/git_sim/commit.py b/src/git_sim/commit.py new file mode 100644 index 0000000..7c02b29 --- /dev/null +++ b/src/git_sim/commit.py @@ -0,0 +1,114 @@ +import sys + +import git +import manim as m + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Commit(GitSimBaseCommand): + def __init__(self, message: str, amend: bool): + super().__init__() + self.message = message + self.amend = amend + + self.n_default = 4 if not self.amend else 5 + self.n = self.n_default + + self.hide_first_tag = True + settings.hide_merged_branches = True + + try: + self.selected_branches.append(self.repo.active_branch.name) + except TypeError: + pass + + if self.amend and self.message == "New commit": + print( + "git-sim error: The --amend flag must be used with the -m flag to specify the amended commit message." + ) + 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} {self.cmd}") + + self.show_intro() + head_commit = self.get_commit() + + if self.amend: + tree = self.repo.tree() + amended = git.Commit.create_from_tree( + self.repo, + tree, + self.message, + ) + head_commit = amended + + self.parse_commits(head_commit) + self.center_frame_on_commit(head_commit) + + if not self.amend: + self.setup_and_draw_parent(head_commit, self.message) + else: + self.draw_ref(head_commit, self.drawnCommitIds[amended.hexsha]) + self.draw_ref( + head_commit, + self.drawnRefs["HEAD"], + text=self.repo.active_branch.name, + color=m.GREEN, + ) + + self.recenter_frame() + self.scale_frame() + + if not self.amend: + self.reset_head_branch("abcdef") + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="Working directory", + second_column_name="Staged files", + third_column_name="New commit", + ) + + self.show_command_as_title() + self.fadeout() + self.show_outro() + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for x in self.repo.index.diff(None): + if "git-sim_media" not in x.a_path: + firstColumnFileNames.add(x.a_path) + + 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/src/git_sim/git_sim_base_command.py b/src/git_sim/git_sim_base_command.py new file mode 100644 index 0000000..c07a8e1 --- /dev/null +++ b/src/git_sim/git_sim_base_command.py @@ -0,0 +1,1401 @@ +import os +import platform +import shutil +import stat +import sys +import tempfile + +import git +import manim as m +import numpy +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) + + def construct(self): + print(f"{settings.INFO_STRING} {type(self).__name__.lower()}") + self.show_intro() + self.parse_commits() + self.fadeout() + self.show_outro() + + def get_commit(self, sha_or_ref="HEAD"): + if self.head_exists(): + return self.repo.commit(sha_or_ref) + return "dark" + + def get_default_commits(self): + defaultCommits = [self.get_commit()] + for x in range(self.n_default - 1): + defaultCommits.append(defaultCommits[-1].parents[0]) + return defaultCommits + + def parse_commits( + self, + commit=None, + 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() + + 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( + commit, i, prevCircle, shift + ) + + if commit != "dark": + if not hide_refs and isNewCommit: + self.draw_head(commit, i, commitId) + self.draw_branch( + commit, i, make_branches_remote=make_branches_remote + ) + self.draw_tag(commit, i) + if ( + not isinstance(arrow, m.CurvedArrow) + and [arrow.start.tolist(), arrow.end.tolist()] not in self.arrow_map + ): + self.draw_arrow(prevCircle, arrow) + self.arrow_map.append([arrow.start.tolist(), arrow.end.tolist()]) + elif ( + isinstance(arrow, m.CurvedArrow) + and [arrow.get_start().tolist(), arrow.get_end().tolist()] + not in self.arrow_map + ): + self.draw_arrow(prevCircle, arrow) + self.arrow_map.append( + [arrow.get_start().tolist(), arrow.get_end().tolist()] + ) + if i == 0 and len(self.drawnRefs) < 2: + self.draw_dark_ref() + + self.first_parse = False + i += 1 + 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() + + if settings.hide_merged_branches: + self.parse_commits(commitParents[0], i, circle) + 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: + for branch in self.get_nonparent_branch_names(): + self.parse_commits(self.get_commit(branch.name)) + + def show_intro(self): + if settings.animate and settings.show_intro: + self.add(self.logo) + + initialCommitText = m.Text( + settings.title, + font=self.font, + font_size=36, + color=self.fontColor, + ).to_edge(m.UP, buff=1) + self.add(initialCommitText) + self.wait(2) + self.play(m.FadeOut(initialCommitText)) + self.play( + self.logo.animate.scale(0.25) + .to_edge(m.UP, buff=0) + .to_edge(m.RIGHT, buff=0) + ) + + self.camera.frame.save_state() + self.play(m.FadeOut(self.logo)) + + else: + self.logo.scale(0.25).to_edge(m.UP, buff=0).to_edge(m.RIGHT, buff=0) + self.camera.frame.save_state() + + def show_outro(self): + if settings.animate and settings.show_outro: + self.play(m.Restore(self.camera.frame)) + + self.play(self.logo.animate.scale(4).set_x(0).set_y(0)) + + outroTopText = m.Text( + settings.outro_top_text, + font=self.font, + font_size=36, + color=self.fontColor, + ).to_edge(m.UP, buff=1) + self.play(m.AddTextLetterByLetter(outroTopText)) + + outroBottomText = m.Text( + settings.outro_bottom_text, + font=self.font, + font_size=36, + color=self.fontColor, + ).to_edge(m.DOWN, buff=1) + self.play(m.AddTextLetterByLetter(outroBottomText)) + + self.wait(3) + + def fadeout(self): + if settings.animate: + self.wait(3) + self.play(m.FadeOut(self.toFadeOut), run_time=1 / settings.speed) + else: + self.wait(0.1) + + def get_centers(self): + centers = [] + for commit in self.drawnCommits.values(): + centers.append(commit.get_center()) + return centers + + def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])): + if commit == "dark": + commit_fill = m.WHITE if settings.light_mode else m.BLACK + elif len(commit.parents) <= 1: + commit_fill = m.RED + else: + commit_fill = m.GRAY + + circle = m.Circle( + stroke_color=commit_fill, + stroke_width=self.commit_stroke_width, + fill_color=commit_fill, + fill_opacity=self.fill_opacity, + ) + circle.height = 1 + + if shift.any(): + circle.shift(shift) + + if prevCircle: + circle.next_to( + prevCircle, m.RIGHT if settings.reverse else m.LEFT, buff=1.5 + ) + + while any((circle.get_center() == c).all() for c in self.get_centers()): + circle.shift(m.DOWN * 4) + + if commit != "dark": + isNewCommit = commit.hexsha not in self.drawnCommits + else: + isNewCommit = True + + if isNewCommit: + start = ( + prevCircle.get_center() + if prevCircle + else (m.LEFT if settings.reverse else m.RIGHT) + ) + end = circle.get_center() + else: + circle.move_to(self.drawnCommits[commit.hexsha].get_center()) + start = ( + prevCircle.get_center() + if prevCircle + else (m.LEFT if settings.reverse else m.RIGHT) + ) + end = self.drawnCommits[commit.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, + ) + + if commit == "dark": + arrow = m.Arrow( + start, end, color=m.WHITE if settings.light_mode else m.BLACK + ) + + length = numpy.linalg.norm(start - end) - (1.5 if start[1] == end[1] else 3) + arrow.set_length(length) + angle = arrow.get_angle() + lineRect = ( + m.Rectangle(height=0.1, width=length, color="#123456") + .move_to(arrow.get_center()) + .rotate(angle) + ) + + for commitCircle in self.drawnCommits.values(): + inter = m.Intersection(lineRect, commitCircle) + if inter.has_points(): + 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]: + arrow.flip(m.RIGHT).shift(m.UP) + + commitId, commitMessage, commit, hide_refs = self.build_commit_id_and_message( + commit, i + ) + commitId.next_to(circle, m.UP) + + if commit != "dark": + self.drawnCommitIds[commit.hexsha] = commitId + + message = m.Text( + "\n".join( + commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) + )[:100], + 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.Text("") + if settings.highlight_commit_messages + else m.AddTextLetterByLetter(commitId), + m.AddTextLetterByLetter(message), + run_time=1 / settings.speed, + ) + elif isNewCommit: + self.add( + circle, + m.Text("") if settings.highlight_commit_messages else commitId, + message, + ) + else: + 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) + if settings.highlight_commit_messages: + self.prevRef = circle + else: + self.prevRef = commitId + + return commitId, circle, arrow, hide_refs + + def get_nonparent_branch_names(self): + branches = [b for b in self.repo.heads if not b.name.startswith("remotes/")] + exclude = [] + for b1 in branches: + for b2 in branches: + if b1.name != b2.name: + if self.repo.is_ancestor(b1.commit, b2.commit): + exclude.append(b1.name) + return [b for b in branches if b.name not in exclude] + + def build_commit_id_and_message(self, commit, i): + hide_refs = False + if commit == "dark": + 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=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=self.ref_fill_opacity + ) + headbox.width = 1 + headbox.height = 0.4 + 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=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, + ).move_to(headbox.get_center()) + + head = m.VGroup(headbox, headText) + + if settings.animate: + self.play(m.Create(head), run_time=1 / settings.speed) + else: + self.add(head) + + 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, make_branches_remote=False): + x = 0 + + remote_tracking_branches = self.get_remote_tracking_branches() + + branches = [branch.name for branch in self.repo.heads] + list( + remote_tracking_branches.keys() + ) + + for selected_branch in self.selected_branches: + branches.insert(0, branches.pop(branches.index(selected_branch))) + + for branch in branches: + if ( + branch not in remote_tracking_branches # local branch + and commit.hexsha == self.repo.heads[branch].commit.hexsha + ) or ( + branch in remote_tracking_branches # remote tracking branch + 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( + 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=self.ref_fill_opacity, + height=0.4, + width=branchText.width + 0.25, + ) + + branchRec.next_to(self.prevRef, m.UP) + branchText.move_to(branchRec.get_center()) + + fullbranch = m.VGroup(branchRec, branchText) + + self.prevRef = fullbranch + + if settings.animate: + self.play(m.Create(fullbranch), run_time=1 / settings.speed) + else: + self.add(fullbranch) + + 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 + + x += 1 + if x >= settings.max_branches_per_commit: + return + + def draw_tag(self, commit, i): + x = 0 + + if self.hide_first_tag and i == 0: + return + + for tag in self.repo.tags: + try: + if commit.hexsha == tag.commit.hexsha: + tagText = m.Text( + tag.name, + 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=self.ref_fill_opacity, + height=0.4, + width=tagText.width + 0.25, + ) + + 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(fulltag), + run_time=1 / settings.speed, + ) + else: + self.add(fulltag) + + 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 + + x += 1 + if x >= settings.max_tags_per_commit: + return + except ValueError: + pass + + def draw_arrow(self, prevCircle, arrow): + if prevCircle: + 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) + + def recenter_frame(self): + if settings.animate: + self.play( + self.camera.frame.animate.move_to(self.toFadeOut.get_center()), + run_time=1 / settings.speed, + ) + else: + self.camera.frame.move_to(self.toFadeOut.get_center()) + + def scale_frame(self): + if settings.animate: + 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 + ), + run_time=1 / settings.speed, + ) + else: + 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 + ) + + def vsplit_frame(self): + if settings.animate: + self.play( + self.camera.frame.animate.scale_to_fit_height( + self.camera.frame.get_height() * 2 + ) + ) + else: + self.camera.frame.scale_to_fit_height(self.camera.frame.get_height() * 2) + + try: + if settings.animate: + self.play( + self.toFadeOut.animate.align_to(self.camera.frame, m.UP).shift( + m.DOWN * 2.25 + ) + ) + else: + 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="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], + self.camera.frame.get_center()[1], + 0, + ), + ( + self.camera.frame.get_right()[0], + self.camera.frame.get_center()[1], + 0, + ), + color=self.fontColor, + ).shift(m.UP * 1.75) + horizontal2 = m.Line( + ( + self.camera.frame.get_left()[0], + self.camera.frame.get_center()[1], + 0, + ), + ( + self.camera.frame.get_right()[0], + self.camera.frame.get_center()[1], + 0, + ), + color=self.fontColor, + ).shift(m.UP * 0.75) + vert1 = m.DashedLine( + ( + self.camera.frame.get_left()[0], + self.camera.frame.get_bottom()[1], + 0, + ), + (self.camera.frame.get_left()[0], horizontal.get_start()[1], 0), + dash_length=0.2, + color=self.fontColor, + ).shift(m.RIGHT * 8) + vert2 = m.DashedLine( + ( + self.camera.frame.get_right()[0], + self.camera.frame.get_bottom()[1], + 0, + ), + (self.camera.frame.get_right()[0], horizontal.get_start()[1], 0), + dash_length=0.2, + color=self.fontColor, + ).shift(m.LEFT * 8) + + if reverse: + 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=self.font, + font_size=28, + color=self.fontColor, + weight=m.BOLD, + ) + .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=self.font, + font_size=28, + color=self.fontColor, + weight=m.BOLD, + ) + .move_to(self.camera.frame.get_center()) + .align_to(firstColumnTitle, m.UP) + ) + thirdColumnTitle = ( + m.Text( + third_column_name, + 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) + ) + + self.toFadeOut.add( + horizontal, + horizontal2, + vert1, + vert2, + firstColumnTitle, + secondColumnTitle, + thirdColumnTitle, + ) + + if settings.animate: + self.play( + m.Create(horizontal), + m.Create(horizontal2), + m.Create(vert1), + m.Create(vert2), + m.AddTextLetterByLetter(firstColumnTitle), + m.AddTextLetterByLetter(secondColumnTitle), + m.AddTextLetterByLetter(thirdColumnTitle), + ) + else: + self.add( + horizontal, + horizontal2, + vert1, + vert2, + firstColumnTitle, + secondColumnTitle, + thirdColumnTitle, + ) + + firstColumnFileNames = set() + secondColumnFileNames = set() + thirdColumnFileNames = set() + + firstColumnArrowMap = {} + secondColumnArrowMap = {} + thirdColumnArrowMap = {} + + self.populate_zones( + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap, + secondColumnArrowMap, + thirdColumnArrowMap, + ) + + firstColumnFiles = m.VGroup() + secondColumnFiles = m.VGroup() + thirdColumnFiles = m.VGroup() + + firstColumnFilesDict = {} + secondColumnFilesDict = {} + thirdColumnFilesDict = {} + + self.create_zone_text( + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnFiles, + secondColumnFiles, + thirdColumnFiles, + firstColumnFilesDict, + secondColumnFilesDict, + thirdColumnFilesDict, + firstColumnTitle, + secondColumnTitle, + thirdColumnTitle, + horizontal2, + ) + + if len(firstColumnFiles): + if settings.animate: + self.play(*[m.AddTextLetterByLetter(d) for d in firstColumnFiles]) + else: + self.add(*[d for d in firstColumnFiles]) + + if len(secondColumnFiles): + if settings.animate: + self.play(*[m.AddTextLetterByLetter(w) for w in secondColumnFiles]) + else: + self.add(*[w for w in secondColumnFiles]) + + if len(thirdColumnFiles): + if settings.animate: + self.play(*[m.AddTextLetterByLetter(s) for s in thirdColumnFiles]) + else: + self.add(*[s for s in thirdColumnFiles]) + + for filename in firstColumnArrowMap: + if reverse: + firstColumnArrowMap[filename].put_start_and_end_on( + ( + firstColumnFilesDict[filename].get_right()[0] + 0.25, + firstColumnFilesDict[filename].get_right()[1], + 0, + ), + ( + secondColumnFilesDict[filename].get_left()[0] - 0.25, + secondColumnFilesDict[filename].get_left()[1], + 0, + ), + ) + else: + firstColumnArrowMap[filename].put_start_and_end_on( + ( + firstColumnFilesDict[filename].get_right()[0] + 0.25, + firstColumnFilesDict[filename].get_right()[1], + 0, + ), + ( + thirdColumnFilesDict[filename].get_left()[0] - 0.25, + thirdColumnFilesDict[filename].get_left()[1], + 0, + ), + ) + if settings.animate: + self.play(m.Create(firstColumnArrowMap[filename])) + else: + self.add(firstColumnArrowMap[filename]) + self.toFadeOut.add(firstColumnArrowMap[filename]) + + for filename in secondColumnArrowMap: + secondColumnArrowMap[filename].put_start_and_end_on( + ( + secondColumnFilesDict[filename].get_right()[0] + 0.25, + secondColumnFilesDict[filename].get_right()[1], + 0, + ), + ( + thirdColumnFilesDict[filename].get_left()[0] - 0.25, + thirdColumnFilesDict[filename].get_left()[1], + 0, + ), + ) + if settings.animate: + self.play(m.Create(secondColumnArrowMap[filename])) + else: + self.add(secondColumnArrowMap[filename]) + self.toFadeOut.add(secondColumnArrowMap[filename]) + + for filename in thirdColumnArrowMap: + thirdColumnArrowMap[filename].put_start_and_end_on( + ( + thirdColumnFilesDict[filename].get_left()[0] - 0.25, + thirdColumnFilesDict[filename].get_left()[1], + 0, + ), + ( + firstColumnFilesDict[filename].get_right()[0] + 0.25, + firstColumnFilesDict[filename].get_right()[1], + 0, + ), + ) + + if settings.animate: + self.play(m.Create(thirdColumnArrowMap[filename])) + else: + self.add(thirdColumnArrowMap[filename]) + self.toFadeOut.add(thirdColumnArrowMap[filename]) + + self.toFadeOut.add(firstColumnFiles, secondColumnFiles, thirdColumnFiles) + + self.firstColumnFiles = firstColumnFiles + self.secondColumnFiles = secondColumnFiles + self.thirdColumnFiles = thirdColumnFiles + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for x in self.repo.index.diff(None): + if "git-sim_media" not in x.a_path: + secondColumnFileNames.add(x.a_path) + + try: + for y in self.repo.index.diff("HEAD"): + if "git-sim_media" not in y.a_path: + thirdColumnFileNames.add(y.a_path) + except git.exc.BadName: + for (y, _stage), entry in self.repo.index.entries.items(): + if "git-sim_media" not in y: + thirdColumnFileNames.add(y) + + for z in self.repo.untracked_files: + if "git-sim_media" not in z: + 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( + self.drawnCommits[commit.hexsha].get_center() + ) + ) + else: + 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( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], + 0, + ) + ), + self.drawnRefs[self.repo.active_branch.name].animate.move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 2 + shift[1], + 0, + ) + ), + ) + else: + self.drawnRefs["HEAD"].move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], + 0, + ) + ) + self.drawnRefs[self.repo.active_branch.name].move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 2 + shift[1], + 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)) + self.play( + self.drawnRefs[self.repo.active_branch.name].animate.next_to( + self.drawnRefs["HEAD"], m.UP + ) + ) + else: + self.drawnRefs["HEAD"].next_to(ref, m.UP) + self.drawnRefs[self.repo.active_branch.name].next_to( + self.drawnRefs["HEAD"], m.UP + ) + + def translate_frame(self, shift): + if settings.animate: + self.play(self.camera.frame.animate.shift(shift)) + else: + self.camera.frame.shift(shift) + + def setup_and_draw_parent( + self, + child, + commitMessage="New commit", + shift=numpy.array([0.0, 0.0, 0.0]), + draw_arrow=True, + color=m.RED, + ): + 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) + + 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=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, + ).next_to(circle, m.UP) + self.toFadeOut.add(commitId) + + commitMessage = commitMessage.split("\n")[0][:40].replace("\n", " ") + message = m.Text( + "\n".join( + commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) + )[:100], + font=self.font, + font_size=14, + color=self.fontColor, + weight=self.font_weight, + ).next_to(circle, m.DOWN) + self.toFadeOut.add(message) + + if settings.animate: + self.play( + self.camera.frame.animate.move_to(circle.get_center()), + m.Create(circle), + m.AddTextLetterByLetter(commitId), + m.AddTextLetterByLetter(message), + run_time=1 / settings.speed, + ) + else: + self.camera.frame.move_to(circle.get_center()) + self.add(circle, commitId, message) + + self.drawnCommits["abcdef"] = circle + self.toFadeOut.add(circle) + + 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 + + def draw_arrow_between_commits(self, startsha, endsha): + start = self.drawnCommits[startsha].get_center() + end = self.drawnCommits[endsha].get_center() + + arrow = DottedLine( + start, end, color=self.fontColor, dot_kwargs={"color": self.fontColor} + ).add_tip() + length = numpy.linalg.norm(start - end) - 1.65 + arrow.set_length(length) + self.draw_arrow(True, arrow) + + def create_dark_commit(self): + return "dark" + + def get_nondark_commits(self): + nondark_commits = [] + return nondark_commits + + def draw_ref(self, commit, top, i=0, text="HEAD", color=m.BLUE): + 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=self.ref_fill_opacity, + height=0.4, + width=refText.width + 0.25, + ) + refbox.next_to(top, m.UP) + refText.move_to(refbox.get_center()) + + ref = m.VGroup(refbox, refText) + + if settings.animate: + self.play(m.Create(ref), run_time=1 / settings.speed) + else: + self.add(ref) + + self.toFadeOut.add(ref) + self.drawnRefs[text] = ref + self.prevRef = ref + + if i == 0 and self.first_parse: + self.topref = self.prevRef + + def draw_dark_ref(self): + refRec = m.Rectangle( + color=m.WHITE if settings.light_mode else m.BLACK, + fill_color=m.WHITE if settings.light_mode else m.BLACK, + height=0.4, + width=1, + ) + refRec.next_to(self.prevRef, m.UP) + self.add(refRec) + self.toFadeOut.add(refRec) + self.prevRef = refRec + + def trim_path(self, 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] + 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 remote_tracking_branches + + 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.Text( + 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 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): + m.Line.__init__(self, *args, **kwargs) + n_dots = int(self.get_length() / dot_spacing) + 1 + dot_spacing = self.get_length() / (n_dots - 1) + unit_vector = self.get_unit_vector() + start = self.start + + self.dot_points = [start + unit_vector * dot_spacing * x for x in range(n_dots)] + self.dots = [m.Dot(point, **dot_kwargs) for point in self.dot_points] + + self.clear_points() + + self.add(*self.dots) + + self.get_start = lambda: self.dot_points[0] + self.get_end = lambda: self.dot_points[-1] + + def get_first_handle(self): + return self.dot_points[-1] + + def get_last_handle(self): + return self.dot_points[-2] 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/src/git_sim/log.py b/src/git_sim/log.py new file mode 100644 index 0000000..954a15a --- /dev/null +++ b/src/git_sim/log.py @@ -0,0 +1,48 @@ +import typer + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings +import numpy +import manim as m + + +class Log(GitSimBaseCommand): + def __init__(self, ctx: typer.Context, n: int, all: bool): + super().__init__() + + n_command = ctx.parent.params.get("n") + self.n_subcommand = n + if self.n_subcommand: + n = self.n_subcommand + else: + n = n_command + self.n = n + self.n_orig = self.n + + all_command = ctx.parent.params.get("all") + self.all_subcommand = all + if self.all_subcommand: + all = self.all_subcommand + else: + all = all_command + self.all = all + + try: + self.selected_branches.append(self.repo.active_branch.name) + 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} {self.cmd}") + self.show_intro() + self.parse_commits() + self.parse_all() + self.recenter_frame() + self.scale_frame() + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() diff --git a/git_sim/logo.png b/src/git_sim/logo.png similarity index 100% rename from git_sim/logo.png rename to src/git_sim/logo.png diff --git a/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/git_sim_rebase.py b/src/git_sim/rebase.py similarity index 63% rename from git_sim/git_sim_rebase.py rename to src/git_sim/rebase.py index f6abf61..a455ae3 100644 --- a/git_sim/git_sim_rebase.py +++ b/src/git_sim/rebase.py @@ -1,56 +1,60 @@ 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 GitSimRebase(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) +class Rebase(GitSimBaseCommand): + def __init__(self, branch: str): + super().__init__() + self.branch = branch try: - git.repo.fun.rev_parse(self.repo, self.args.branch[0]) + git.repo.fun.rev_parse(self.repo, self.branch) except git.exc.BadName: print( "git-sim error: '" - + self.args.branch[0] + + self.branch + "' is not a valid Git ref or identifier." ) sys.exit(1) - if self.args.branch[0] in [branch.name for branch in self.repo.heads]: - self.selected_branches.append(self.args.branch[0]) + 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}" + def construct(self): - print("Simulating: git " + self.args.subcommand + " " + self.args.branch[0]) + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print(f"{settings.INFO_STRING} {self.cmd}") - if self.args.branch[0] in self.repo.git.branch( + if self.branch in self.repo.git.branch( "--contains", self.repo.active_branch.name ): print( "git-sim error: Branch '" + self.repo.active_branch.name + "' is already included in the history of active branch '" - + self.args.branch[0] + + self.branch + "'." ) sys.exit(1) if self.repo.active_branch.name in self.repo.git.branch( - "--contains", self.args.branch[0] + "--contains", self.branch ): print( "git-sim error: Branch '" - + self.args.branch[0] + + self.branch + "' is already based on active branch '" + self.repo.active_branch.name + "'." @@ -58,35 +62,32 @@ def construct(self): sys.exit(1) self.show_intro() - self.get_commits(start=self.args.branch[0]) - self.parse_commits(self.commits[0]) - self.orig_commits = self.commits - self.i = 0 - self.get_commits() + branch_commit = self.get_commit(self.branch) + self.parse_commits(branch_commit) + head_commit = self.get_commit() reached_base = False - for commit in self.commits: - if commit != "dark" and self.args.branch[0] in self.repo.git.branch( + for commit in self.get_default_commits(): + if commit != "dark" and self.branch in self.repo.git.branch( "--contains", commit ): reached_base = True - self.parse_commits( - self.commits[0], shift=4 * m.DOWN, dots=False if reached_base else True - ) - self.center_frame_on_commit(self.orig_commits[0]) + self.parse_commits(head_commit, shift=4 * m.DOWN) + self.parse_all() + self.center_frame_on_commit(branch_commit) to_rebase = [] i = 0 - current = self.commits[i] - while self.args.branch[0] not in self.repo.git.branch("--contains", current): + current = head_commit + while self.branch not in self.repo.git.branch("--contains", current): to_rebase.append(current) i += 1 - if i >= len(self.commits): + if i >= self.n: break - current = self.commits[i] + current = self.get_default_commits()[i] - parent = self.orig_commits[0].hexsha + parent = branch_commit.hexsha for j, tr in enumerate(reversed(to_rebase)): if not reached_base and j == 0: @@ -99,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() @@ -109,18 +112,30 @@ 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], - m.LEFT if self.args.reverse else m.RIGHT, + m.LEFT if settings.reverse else m.RIGHT, buff=1.5, ) circle.shift(shift) 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) @@ -135,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) @@ -146,19 +161,19 @@ 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) self.toFadeOut.add(message) - if self.args.animate: + if settings.animate: self.play( self.camera.frame.animate.move_to(circle.get_center()), m.Create(circle), m.AddTextLetterByLetter(commitId), m.AddTextLetterByLetter(message), - run_time=1 / self.args.speed, + run_time=1 / settings.speed, ) else: self.camera.frame.move_to(circle.get_center()) @@ -168,8 +183,8 @@ def setup_and_draw_parent( self.toFadeOut.add(circle) if draw_arrow: - if self.args.animate: - self.play(m.Create(arrow), run_time=1 / self.args.speed) + if settings.animate: + self.play(m.Create(arrow), run_time=1 / settings.speed) else: self.add(arrow) self.toFadeOut.add(arrow) 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/git_sim_reset.py b/src/git_sim/reset.py similarity index 56% rename from git_sim/git_sim_reset.py rename to src/git_sim/reset.py index 4762772..a1fed65 100644 --- a/git_sim/git_sim_reset.py +++ b/src/git_sim/reset.py @@ -1,83 +1,80 @@ import sys -from argparse import Namespace +from enum import Enum import git import manim as m +from git_sim.enums import ResetMode from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings -class GitSimReset(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) +class Reset(GitSimBaseCommand): + def __init__( + self, commit: str, mode: ResetMode, soft: bool, mixed: bool, hard: bool + ): + super().__init__() + self.commit = commit + self.mode = mode + settings.hide_merged_branches = True try: - self.resetTo = git.repo.fun.rev_parse(self.repo, self.args.commit) + self.resetTo = git.repo.fun.rev_parse(self.repo, self.commit) except git.exc.BadName: print( - "git-sim error: '" - + self.args.commit - + "' is not a valid Git ref or identifier." + f"git-sim error: '{self.commit}' is not a valid Git ref or identifier." ) sys.exit(1) - self.commitsSinceResetTo = list( - self.repo.iter_commits(self.args.commit + "...HEAD") - ) - self.maxrefs = 2 - self.hide_first_tag = True + self.commitsSinceResetTo = list(self.repo.iter_commits(self.commit + "...HEAD")) + self.n = self.n_default try: self.selected_branches.append(self.repo.active_branch.name) except TypeError: pass - if self.args.hard: - self.args.mode = "hard" - if self.args.mixed: - self.args.mode = "mixed" - if self.args.soft: - self.args.mode = "soft" + if hard: + self.mode = ResetMode.HARD + if mixed: + self.mode = ResetMode.MIXED + 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): - print( - "Simulating: git " - + self.args.subcommand - + (" --" + self.args.mode if self.args.mode != "default" else "") - + " " - + self.args.commit - ) + 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.get_commits() - self.parse_commits(self.commits[self.i]) + self.parse_commits() self.recenter_frame() self.scale_frame() 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, dots=False): + 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 self.i == 3 and self.resetTo.hexsha not in [ - commit.hexsha for commit in self.get_nondark_commits() + 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 self.i == 4 and self.resetTo.hexsha not in [ - commit.hexsha for commit in self.get_nondark_commits() + elif i == 4 and self.resetTo.hexsha not in [ + c.hexsha for c in self.get_default_commits() ]: commitId = m.Text( self.resetTo.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -87,7 +84,7 @@ def build_commit_id_and_message(self, commit, dots=False): else: commitId = m.Text( commit.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -109,32 +106,33 @@ def populate_zones( thirdColumnFileNames, firstColumnArrowMap={}, secondColumnArrowMap={}, + thirdColumnArrowMap={}, ): for commit in self.commitsSinceResetTo: if commit.hexsha == self.resetTo.hexsha: break for filename in commit.stats.files: - if self.args.mode == "soft": + if self.mode == ResetMode.SOFT: thirdColumnFileNames.add(filename) - elif self.args.mode == "mixed" or self.args.mode == "default": + elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): secondColumnFileNames.add(filename) - elif self.args.mode == "hard": + elif self.mode == ResetMode.HARD: firstColumnFileNames.add(filename) for x in self.repo.index.diff(None): if "git-sim_media" not in x.a_path: - if self.args.mode == "soft": + if self.mode == ResetMode.SOFT: secondColumnFileNames.add(x.a_path) - elif self.args.mode == "mixed" or self.args.mode == "default": + elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): secondColumnFileNames.add(x.a_path) - elif self.args.mode == "hard": + elif self.mode == ResetMode.HARD: firstColumnFileNames.add(x.a_path) for y in self.repo.index.diff("HEAD"): if "git-sim_media" not in y.a_path: - if self.args.mode == "soft": + if self.mode == ResetMode.SOFT: thirdColumnFileNames.add(y.a_path) - elif self.args.mode == "mixed" or self.args.mode == "default": + elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): secondColumnFileNames.add(y.a_path) - elif self.args.mode == "hard": + elif self.mode == ResetMode.HARD: firstColumnFileNames.add(y.a_path) diff --git a/src/git_sim/restore.py b/src/git_sim/restore.py new file mode 100644 index 0000000..1050611 --- /dev/null +++ b/src/git_sim/restore.py @@ -0,0 +1,79 @@ +import sys +import manim as m + +from typing import List + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Restore(GitSimBaseCommand): + 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 + + try: + self.selected_branches.append(self.repo.active_branch.name) + except TypeError: + pass + + 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} {self.cmd}") + + self.show_intro() + self.parse_commits() + self.recenter_frame() + self.scale_frame() + self.vsplit_frame() + self.setup_and_draw_zones(reverse=True) + self.show_command_as_title() + self.fadeout() + self.show_outro() + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for x in self.repo.index.diff(None): + if "git-sim_media" not in x.a_path: + secondColumnFileNames.add(x.a_path) + for file in self.files: + if file == x.a_path: + thirdColumnFileNames.add(x.a_path) + secondColumnArrowMap[x.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + + for y in self.repo.index.diff("HEAD"): + if "git-sim_media" not in y.a_path: + firstColumnFileNames.add(y.a_path) + for file in self.files: + if file == y.a_path: + secondColumnFileNames.add(y.a_path) + firstColumnArrowMap[y.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) diff --git a/git_sim/git_sim_revert.py b/src/git_sim/revert.py similarity index 61% rename from git_sim/git_sim_revert.py rename to src/git_sim/revert.py index 460b88d..8997850 100644 --- a/git_sim/git_sim_revert.py +++ b/src/git_sim/revert.py @@ -1,31 +1,32 @@ 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 GitSimRevert(GitSimBaseCommand): - def __init__(self, args: Namespace): - super().__init__(args=args) +class Revert(GitSimBaseCommand): + def __init__(self, commit: str): + super().__init__() + self.commit = commit try: - self.revert = git.repo.fun.rev_parse(self.repo, self.args.commit) + self.revert = git.repo.fun.rev_parse(self.repo, self.commit) except git.exc.BadName: print( "git-sim error: '" - + self.args.commit + + self.commit + "' is not a valid Git ref or identifier." ) sys.exit(1) - self.maxrefs = 2 - self.defaultNumCommits = 4 - self.numCommits = 4 - self.hide_first_tag = True + self.n_default = 4 + self.n = self.n_default + settings.hide_merged_branches = True + self.zone_title_offset += 0.1 try: @@ -33,13 +34,15 @@ def __init__(self, args: Namespace): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()} {self.commit}" + def construct(self): - print("Simulating: git " + self.args.subcommand + " " + self.args.commit) + 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.get_commits() - self.parse_commits(self.commits[self.i]) - self.center_frame_on_commit(self.commits[0]) + self.parse_commits() + self.center_frame_on_commit(self.get_commit()) self.setup_and_draw_revert_commit() self.recenter_frame() self.scale_frame() @@ -50,28 +53,27 @@ 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, dots=False): + 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 self.i == 2 and self.revert.hexsha not in [ - commit.hexsha for commit in self.commits + 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 self.i == 3 and self.revert.hexsha not in [ - commit.hexsha for commit in self.commits + elif i == 3 and self.revert.hexsha not in [ + commit.hexsha for commit in self.get_default_commits() ]: commitId = m.Text( self.revert.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -80,7 +82,7 @@ def build_commit_id_and_message(self, commit, dots=False): else: commitId = m.Text( commit.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -88,22 +90,34 @@ def build_commit_id_and_message(self, commit, dots=False): 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.commits[0].hexsha], - m.LEFT if self.args.reverse else m.RIGHT, + self.drawnCommits[self.get_commit().hexsha], + m.LEFT if settings.reverse else m.RIGHT, buff=1.5, ) start = circle.get_center() - end = self.drawnCommits[self.commits[0].hexsha].get_center() - arrow = m.Arrow(start, end, color=self.fontColor) + end = self.drawnCommits[self.get_commit().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 ).next_to(circle, m.UP) self.toFadeOut.add(commitId) @@ -113,19 +127,19 @@ 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) self.toFadeOut.add(message) - if self.args.animate: + if settings.animate: self.play( self.camera.frame.animate.move_to(circle.get_center()), m.Create(circle), m.AddTextLetterByLetter(commitId), m.AddTextLetterByLetter(message), - run_time=1 / self.args.speed, + run_time=1 / settings.speed, ) else: self.camera.frame.move_to(circle.get_center()) @@ -134,8 +148,8 @@ def setup_and_draw_revert_commit(self): self.drawnCommits["abcdef"] = circle self.toFadeOut.add(circle) - if self.args.animate: - self.play(m.Create(arrow), run_time=1 / self.args.speed) + if settings.animate: + self.play(m.Create(arrow), run_time=1 / settings.speed) else: self.add(arrow) @@ -148,6 +162,7 @@ def populate_zones( thirdColumnFileNames, firstColumnArrowMap={}, secondColumnArrowMap={}, + thirdColumnArrowMap={}, ): for filename in self.revert.stats.files: secondColumnFileNames.add(filename) 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/src/git_sim/stash.py b/src/git_sim/stash.py new file mode 100644 index 0000000..a30d7fd --- /dev/null +++ b/src/git_sim/stash.py @@ -0,0 +1,202 @@ +import re +import sys +from enum import Enum +import manim as m + +from typing import List + +from git_sim.enums import StashSubCommand +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Stash(GitSimBaseCommand): + 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 + self.command = command + 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: + pass + + if self.command in [StashSubCommand.PUSH, None]: + 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.files: + self.files = [x.a_path for x in self.repo.index.diff(None)] + [ + y.a_path for y in self.repo.index.diff("HEAD") + ] + elif self.files: + if ( + not settings.stdout + and not settings.output_only_path + and not settings.quiet + ): + print( + "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} {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="Stashed changes", + ) + 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) + + "" + if self.command == StashSubCommand.POP + else 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={}, + ): + if self.command in [StashSubCommand.POP, StashSubCommand.APPLY]: + try: + stashedFileNames = self.repo.git.stash( + "show", "--name-only", self.stash_index + ) + 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): + firstColumnFileNames.add(x.a_path) + for file in self.files: + if file == x.a_path: + thirdColumnFileNames.add(x.a_path) + firstColumnArrowMap[x.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + + for y in self.repo.index.diff("HEAD"): + secondColumnFileNames.add(y.a_path) + for file in self.files: + if file == y.a_path: + thirdColumnFileNames.add(y.a_path) + secondColumnArrowMap[y.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + + def parse_stash_format(self, s): + # Regular expression to match either a plain integer or stash@{integer} + match = re.match(r"^(?:stash@\{(\d+)\}|\b(\d+)\b)$", s) + if match: + # match.group(1) is the integer in the stash@{integer} format + # match.group(2) is the integer if it's just a plain number + # One of these groups will be None, the other will have our number as a string + number_str = match.group(1) or match.group(2) + return int(number_str) + return None diff --git a/src/git_sim/status.py b/src/git_sim/status.py new file mode 100644 index 0000000..f96391f --- /dev/null +++ b/src/git_sim/status.py @@ -0,0 +1,27 @@ +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Status(GitSimBaseCommand): + def __init__(self): + super().__init__() + try: + self.selected_branches.append(self.repo.active_branch.name) + except TypeError: + 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} {self.cmd}") + self.show_intro() + self.parse_commits() + self.recenter_frame() + self.scale_frame() + self.vsplit_frame() + self.setup_and_draw_zones() + self.show_command_as_title() + self.fadeout() + self.show_outro() diff --git a/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)