├── .github └── FUNDING.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── src └── git_sim │ ├── __init__.py │ ├── __main__.py │ ├── add.py │ ├── animations.py │ ├── branch.py │ ├── checkout.py │ ├── cherrypick.py │ ├── clean.py │ ├── clone.py │ ├── commands.py │ ├── commit.py │ ├── config.py │ ├── enums.py │ ├── fetch.py │ ├── git_sim_base_command.py │ ├── init.py │ ├── log.py │ ├── logo.png │ ├── merge.py │ ├── mv.py │ ├── pull.py │ ├── push.py │ ├── rebase.py │ ├── remote.py │ ├── reset.py │ ├── restore.py │ ├── revert.py │ ├── rm.py │ ├── settings.py │ ├── stash.py │ ├── status.py │ ├── switch.py │ └── tag.py └── tests ├── README.md ├── e2e_tests ├── ProggyClean.ttf ├── conftest.py ├── reference_files │ ├── git-sim-add.png │ ├── git-sim-branch.png │ ├── git-sim-checkout.png │ ├── git-sim-cherry_pick.png │ ├── git-sim-clean.png │ ├── git-sim-commit.png │ ├── git-sim-log.png │ ├── git-sim-merge.png │ ├── git-sim-mv.png │ ├── git-sim-rebase.png │ ├── git-sim-reset.png │ ├── git-sim-restore.png │ ├── git-sim-revert.png │ ├── git-sim-rm.png │ ├── git-sim-stash.png │ ├── git-sim-status.png │ ├── git-sim-switch.png │ └── git-sim-tag.png ├── test_core_commands.py └── utils.py └── unit_tests └── test.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [initialcommit-com] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | *.pyc 4 | media/ 5 | git-sim_media/ 6 | build/ 7 | dist/ 8 | git_sim.egg-info/ 9 | 10 | .venv/ 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Git-Sim 2 | 3 | Thanks for checking out Git-Sim and for your interest in contributing! I hope 4 | that we can work together to build an incredible tool for developers to 5 | visualize Git commands. 6 | 7 | ## Reporting bugs 8 | 9 | To report a bug you found, please open a [GitHub issue](https://github.com/initialcommit-com/git-sim/issues/new) 10 | and describe the error or problem in detail. Please check [existing issues](https://github.com/initialcommit-com/git-sim/issues) 11 | to make sure it hasn't already been reported. 12 | 13 | When submitting a new issue, it helps to include: 14 | 15 | 1) The steps you took that lead to the issue 16 | 2) Any error message(s) that you received 17 | 3) A description of any unexpected behavior 18 | 4) The version of Git-Sim you're running 19 | 5) The version of Python you're running and whether it's system-level or in a virtual environment 20 | 6) The operating system and version you're running 21 | 22 | ## Suggesting enhancements or new features 23 | 24 | If you've got a cool idea for a feature that you'd like to see implemented in 25 | Git-Sim, we'd love to hear about it! 26 | 27 | To suggest an enhancement or new feature, please open a [GitHub issue](https://github.com/initialcommit-com/git-sim/issues/new) 28 | and describe your proposed idea in detail. Please include why you think this 29 | idea would be beneficial to the Git-Sim user base. 30 | 31 | ## Your first code contribution 32 | 33 | Note: Git-Sim is a new project so these steps are not fully optimized yet, but 34 | they should get you going. 35 | 36 | To start contributing code to Git-Sim, you'll need to perform the following 37 | steps: 38 | 39 | 1) Install [manim and manim dependencies for your OS](https://www.manim.community/) 40 | 2) [Fork the Git-Sim codebase](https://github.com/initialcommit-com/git-sim/fork) 41 | so that you have a copy on GitHub that you can clone and work with 42 | 3) Clone the codebase down to your local machine 43 | 4) Checkout and commit new work to the `dev` branch 44 | 5) If you previously installed Git-Sim normally using pip, uninstall it first using: 45 | 46 | ```console 47 | $ pip uninstall git-sim 48 | ``` 49 | 50 | 6) To run the code locally from source, install the development package by running: 51 | 52 | ```console 53 | $ cd path/to/git-sim 54 | $ python -m pip install -e .[dev] 55 | ``` 56 | 57 | > Explanation: `python -m pip` uses the `pip` module of the currently active python interpreter. 58 | > 59 | > `install -e .[dev]` is the command that `pip` executes, where 60 | > 61 | > `-e` means to make it an [editable install](https://setuptools.pypa.io/en/latest/userguide/development_mode.html), 62 | > 63 | > the dot `.` refers to the current directory, 64 | > 65 | > 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)). 66 | 67 | This will install sources from your cloned repo such that you can edit the source and the changes are reflected instantly. 68 | 69 | If you already have the dependencies, you can ignore those using the `--no-deps` flag: 70 | 71 | ```console 72 | $ python -m pip install --no-deps -e . 73 | ``` 74 | 75 | 7) You can run your local Git-Sim commands from within other local repos like this: 76 | 77 | ```console 78 | $ git-sim [global options] [subcommand options] 79 | ``` 80 | 81 | For example, you can simulate the `git add` command locally like this: 82 | 83 | ```console 84 | $ cd path/to/any/local/git/repo 85 | $ git-sim --animate add newfile.txt 86 | ``` 87 | 88 | 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 89 | to review your code, provide feedback, and merge it into the codebase! 90 | 91 | ## Code style guide 92 | 93 | Since Git-Sim is a new project, we don't have an official code style set in 94 | stone. For now just try and make your new code fit in with the existing style 95 | you find in the codebase, and we'll update this section later if that changes. 96 | 97 | ## Code Formatting 98 | 99 | This project uses the [`black`](https://github.com/psf/black) code formatter to keep all code in a constistent format. 100 | 101 | Please install it in your development environment and run `black path/to/changed/files` before committing any changes. 102 | 103 | ## Commit conventions 104 | 105 | We have a few simple rules for Git-Sim commit messages: 106 | 107 | 1) Write commit messages in the [imperative mood](https://initialcommit.com/blog/Git-Commit-Message-Imperative-Mood) 108 | 2) Add a signoff trailer to your commits by using the `-s` flag when you make 109 | your commits, like this: 110 | 111 | ``` 112 | $ git commit -sm "Fixed xyz..." 113 | ``` 114 | 115 | ## Questions 116 | 117 | If you have any additional questions about contributing to Git-Sim, feel free 118 | to [send me an email at jacob@initialcommit.io](mailto:jacob@initialcommit.io). 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /usr/src/git-sim 4 | 5 | RUN apt update 6 | 7 | RUN apt -y install build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg 8 | 9 | RUN pip3 install manim 10 | 11 | RUN pip3 install git-sim 12 | 13 | ENTRYPOINT [ "git-sim" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/git_sim/logo.png 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "git-sim" 7 | authors = [{ name = "Jacob Stopak", email = "jacob@initialcommit.io" }] 8 | description = "Simulate Git commands on your own repos by generating an image (default) or video visualization depicting the command's behavior." 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | keywords = [ 12 | "git", 13 | "sim", 14 | "simulation", 15 | "simulate", 16 | "git-simulate", 17 | "git-simulation", 18 | "git-sim", 19 | "manim", 20 | "animation", 21 | "gitanimation", 22 | "image", 23 | "video", 24 | "dryrun", 25 | "dry-run", 26 | ] 27 | license = { text = "GPL-2.0" } 28 | classifiers = [ 29 | "Programming Language :: Python :: 3", 30 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 31 | "Operating System :: OS Independent", 32 | ] 33 | dependencies = [ 34 | "git-dummy", 35 | "gitpython", 36 | "manim", 37 | "opencv-python-headless", 38 | "pydantic_settings", 39 | "typer", 40 | "fonttools", 41 | ] 42 | dynamic = ["version"] 43 | 44 | [tool.setuptools.dynamic] 45 | version = { attr = "git_sim.__version__" } 46 | 47 | [project.optional-dependencies] 48 | dev = ["black", "numpy", "pillow", "pytest"] 49 | 50 | [project.scripts] 51 | git-sim = "git_sim.__main__:app" 52 | 53 | [project.urls] 54 | Homepage = "https://initialcommit.com/tools/git-sim" 55 | Source = "https://github.com/initialcommit-com/git-sim" 56 | -------------------------------------------------------------------------------- /src/git_sim/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.5" 2 | -------------------------------------------------------------------------------- /src/git_sim/__main__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import datetime 3 | import os 4 | import pathlib 5 | import sys 6 | import time 7 | from pathlib import Path 8 | 9 | import typer 10 | import manim as m 11 | 12 | from fontTools.ttLib import TTFont 13 | 14 | import git_sim.commands 15 | from git_sim.settings import ( 16 | ColorByOptions, 17 | StyleOptions, 18 | ImgFormat, 19 | VideoFormat, 20 | settings, 21 | ) 22 | 23 | app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}) 24 | 25 | 26 | def get_font_name(font_path): 27 | """Get the name of a font from its .ttf file.""" 28 | font = TTFont(font_path) 29 | return font["name"].getName(4, 3, 1, 1033).toUnicode() 30 | 31 | 32 | def version_callback(value: bool) -> None: 33 | if value: 34 | print(f"git-sim version {git_sim.__version__}") 35 | raise typer.Exit() 36 | 37 | 38 | @app.callback(no_args_is_help=True) 39 | def main( 40 | ctx: typer.Context, 41 | animate: bool = typer.Option( 42 | settings.animate, 43 | help="Animate the simulation and output as an mp4 video", 44 | ), 45 | n: int = typer.Option( 46 | settings.n, 47 | "-n", 48 | help="Number of commits to display from each branch head", 49 | ), 50 | auto_open: bool = typer.Option( 51 | settings.auto_open, 52 | "--auto-open", 53 | " /-d", 54 | help="Enable / disable the automatic opening of the image/video file after generation", 55 | ), 56 | img_format: ImgFormat = typer.Option( 57 | settings.img_format, 58 | help="Output format for the image files.", 59 | ), 60 | light_mode: bool = typer.Option( 61 | settings.light_mode, 62 | help="Enable light-mode with white background", 63 | ), 64 | transparent_bg: bool = typer.Option( 65 | settings.transparent_bg, 66 | "--transparent-bg", 67 | help="Make background transparent", 68 | ), 69 | logo: pathlib.Path = typer.Option( 70 | settings.logo, 71 | help="The path to a custom logo to use in the animation intro/outro", 72 | ), 73 | low_quality: bool = typer.Option( 74 | settings.low_quality, 75 | "--low-quality", 76 | help="Render output video in low quality, useful for faster testing", 77 | ), 78 | max_branches_per_commit: int = typer.Option( 79 | settings.max_branches_per_commit, 80 | help="Maximum number of branch labels to display for each commit", 81 | ), 82 | max_tags_per_commit: int = typer.Option( 83 | settings.max_tags_per_commit, 84 | help="Maximum number of tags to display for each commit", 85 | ), 86 | media_dir: pathlib.Path = typer.Option( 87 | settings.media_dir, 88 | help="The path to output the animation data and video file", 89 | ), 90 | outro_bottom_text: str = typer.Option( 91 | settings.outro_bottom_text, 92 | help="Custom text to display below the logo during the outro", 93 | ), 94 | outro_top_text: str = typer.Option( 95 | settings.outro_top_text, 96 | help="Custom text to display above the logo during the outro", 97 | ), 98 | reverse: bool = typer.Option( 99 | settings.reverse, 100 | "--reverse", 101 | "-r", 102 | help="Display commit history in the reverse direction", 103 | ), 104 | show_intro: bool = typer.Option( 105 | settings.show_intro, 106 | help="Add an intro sequence with custom logo and title", 107 | ), 108 | show_outro: bool = typer.Option( 109 | settings.show_outro, 110 | help="Add an outro sequence with custom logo and text", 111 | ), 112 | speed: float = typer.Option( 113 | settings.speed, 114 | help="A multiple of the standard 1x animation speed (ex: 2 = twice as fast, 0.5 = half as fast)", 115 | ), 116 | title: str = typer.Option( 117 | settings.title, 118 | help="Custom title to display at the beginning of the animation", 119 | ), 120 | video_format: VideoFormat = typer.Option( 121 | settings.video_format.value, 122 | help="Output format for the animation files.", 123 | case_sensitive=False, 124 | ), 125 | stdout: bool = typer.Option( 126 | settings.stdout, 127 | help="Write raw image data to stdout while suppressing all other program output", 128 | ), 129 | output_only_path: bool = typer.Option( 130 | settings.output_only_path, 131 | help="Only output the path to the generated media file to stdout (useful for other programs to ingest)", 132 | ), 133 | quiet: bool = typer.Option( 134 | settings.quiet, 135 | "--quiet", 136 | "-q", 137 | help="Suppress all output except errors", 138 | ), 139 | invert_branches: bool = typer.Option( 140 | settings.invert_branches, 141 | help="Invert positioning of branches by reversing order of multiple parents where applicable", 142 | ), 143 | hide_merged_branches: bool = typer.Option( 144 | settings.hide_merged_branches, 145 | help="Hide commits from merged branches, i.e. only display mainline commits", 146 | ), 147 | all: bool = typer.Option( 148 | settings.all, 149 | help="Display all local branches in the log output", 150 | ), 151 | color_by: ColorByOptions = typer.Option( 152 | settings.color_by, 153 | help="Color commits by parameter", 154 | ), 155 | highlight_commit_messages: bool = typer.Option( 156 | settings.highlight_commit_messages, 157 | help="Make the displayed commit messages more prominent", 158 | ), 159 | version: bool = typer.Option( 160 | False, 161 | "--version", 162 | "-v", 163 | help="Show the version of git-sim and exit", 164 | callback=version_callback, 165 | ), 166 | style: StyleOptions = typer.Option( 167 | settings.style.value, 168 | help="Graphical style of the output image or animated video", 169 | ), 170 | font: str = typer.Option( 171 | settings.font, 172 | help="Font family used to display rendered text", 173 | ), 174 | show_command_as_title: bool = typer.Option( 175 | settings.show_command_as_title, 176 | help="Use the simulated git command as the title of the output image or animated video", 177 | ), 178 | ): 179 | import git 180 | from manim import WHITE, config 181 | 182 | settings.animate = animate 183 | settings.n = n 184 | settings.auto_open = auto_open 185 | settings.img_format = img_format 186 | settings.light_mode = light_mode 187 | settings.transparent_bg = transparent_bg 188 | settings.logo = logo 189 | settings.low_quality = low_quality 190 | settings.max_branches_per_commit = max_branches_per_commit 191 | settings.max_tags_per_commit = max_tags_per_commit 192 | settings.media_dir = os.path.join(os.path.expanduser(media_dir), "git-sim_media") 193 | settings.outro_bottom_text = outro_bottom_text 194 | settings.outro_top_text = outro_top_text 195 | settings.reverse = reverse 196 | settings.show_intro = show_intro 197 | settings.show_outro = show_outro 198 | settings.speed = speed 199 | settings.title = title 200 | settings.video_format = video_format 201 | settings.stdout = stdout 202 | settings.output_only_path = output_only_path 203 | settings.quiet = quiet 204 | settings.invert_branches = invert_branches 205 | settings.hide_merged_branches = hide_merged_branches 206 | settings.all = all 207 | settings.color_by = color_by 208 | settings.highlight_commit_messages = highlight_commit_messages 209 | settings.style = style 210 | settings.show_command_as_title = show_command_as_title 211 | 212 | # If font is a path, define the context that will be used when using Manim. 213 | if Path(font).exists(): 214 | font_path = Path(font) 215 | settings.font_context = m.register_font(font_path) 216 | settings.font = get_font_name(font_path) 217 | else: 218 | settings.font_context = contextlib.nullcontext() 219 | settings.font = font 220 | 221 | try: 222 | if sys.platform == "linux" or sys.platform == "darwin": 223 | repo_name = git.repo.Repo( 224 | search_parent_directories=True 225 | ).working_tree_dir.split("/")[-1] 226 | elif sys.platform == "win32": 227 | repo_name = git.repo.Repo( 228 | search_parent_directories=True 229 | ).working_tree_dir.split("\\")[-1] 230 | except git.InvalidGitRepositoryError as e: 231 | repo_name = "" 232 | 233 | settings.media_dir = os.path.join(settings.media_dir, repo_name) 234 | 235 | config.media_dir = settings.media_dir 236 | config.verbosity = "ERROR" 237 | 238 | if settings.low_quality: 239 | config.quality = "low_quality" 240 | 241 | if settings.light_mode: 242 | config.background_color = WHITE 243 | 244 | if settings.transparent_bg: 245 | settings.img_format = ImgFormat.PNG 246 | 247 | t = datetime.datetime.fromtimestamp(time.time()).strftime("%m-%d-%y_%H-%M-%S") 248 | config.output_file = "git-sim-" + ctx.invoked_subcommand + "_" + t + ".mp4" 249 | 250 | 251 | app.command()(git_sim.commands.add) 252 | app.command()(git_sim.commands.branch) 253 | app.command()(git_sim.commands.checkout) 254 | app.command()(git_sim.commands.cherry_pick) 255 | app.command()(git_sim.commands.clean) 256 | app.command()(git_sim.commands.clone) 257 | app.command()(git_sim.commands.commit) 258 | app.command()(git_sim.commands.config) 259 | app.command()(git_sim.commands.fetch) 260 | app.command()(git_sim.commands.init) 261 | app.command()(git_sim.commands.log) 262 | app.command()(git_sim.commands.merge) 263 | app.command()(git_sim.commands.mv) 264 | app.command()(git_sim.commands.pull) 265 | app.command()(git_sim.commands.push) 266 | app.command()(git_sim.commands.rebase) 267 | app.command()(git_sim.commands.remote) 268 | app.command()(git_sim.commands.reset) 269 | app.command()(git_sim.commands.restore) 270 | app.command()(git_sim.commands.revert) 271 | app.command()(git_sim.commands.rm) 272 | app.command()(git_sim.commands.stash) 273 | app.command()(git_sim.commands.status) 274 | app.command()(git_sim.commands.switch) 275 | app.command()(git_sim.commands.tag) 276 | 277 | 278 | if __name__ == "__main__": 279 | app() 280 | -------------------------------------------------------------------------------- /src/git_sim/add.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | import manim as m 4 | 5 | from typing import List 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Add(GitSimBaseCommand): 12 | def __init__(self, files: List[str]): 13 | super().__init__() 14 | self.hide_first_tag = True 15 | self.allow_no_commits = True 16 | self.files = files 17 | settings.hide_merged_branches = True 18 | self.n = self.n_default 19 | 20 | try: 21 | self.selected_branches.append(self.repo.active_branch.name) 22 | except TypeError: 23 | pass 24 | 25 | for file in self.files: 26 | if file not in [x.a_path for x in self.repo.index.diff(None)] + [ 27 | z for z in self.repo.untracked_files 28 | ]: 29 | print(f"git-sim error: No modified file with name: '{file}'") 30 | sys.exit() 31 | 32 | self.cmd += f"{type(self).__name__.lower()} {' '.join(self.files)}" 33 | 34 | def construct(self): 35 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 36 | print(f"{settings.INFO_STRING} {self.cmd}") 37 | 38 | self.show_intro() 39 | self.parse_commits() 40 | self.recenter_frame() 41 | self.scale_frame() 42 | self.vsplit_frame() 43 | self.setup_and_draw_zones() 44 | self.show_command_as_title() 45 | self.fadeout() 46 | self.show_outro() 47 | 48 | def populate_zones( 49 | self, 50 | firstColumnFileNames, 51 | secondColumnFileNames, 52 | thirdColumnFileNames, 53 | firstColumnArrowMap={}, 54 | secondColumnArrowMap={}, 55 | thirdColumnArrowMap={}, 56 | ): 57 | for x in self.repo.index.diff(None): 58 | if "git-sim_media" not in x.a_path: 59 | secondColumnFileNames.add(x.a_path) 60 | for file in self.files: 61 | if file == x.a_path: 62 | thirdColumnFileNames.add(x.a_path) 63 | secondColumnArrowMap[x.a_path] = m.Arrow( 64 | stroke_width=3, color=self.fontColor 65 | ) 66 | try: 67 | for y in self.repo.index.diff("HEAD"): 68 | if "git-sim_media" not in y.a_path: 69 | thirdColumnFileNames.add(y.a_path) 70 | except git.exc.BadName: 71 | for (y, _stage), entry in self.repo.index.entries.items(): 72 | if "git-sim_media" not in y: 73 | thirdColumnFileNames.add(y) 74 | 75 | for z in self.repo.untracked_files: 76 | if "git-sim_media" not in z: 77 | firstColumnFileNames.add(z) 78 | for file in self.files: 79 | if file == z: 80 | thirdColumnFileNames.add(z) 81 | firstColumnArrowMap[z] = m.Arrow( 82 | stroke_width=3, color=self.fontColor 83 | ) 84 | -------------------------------------------------------------------------------- /src/git_sim/animations.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import inspect 3 | import os 4 | import subprocess 5 | import sys 6 | import time 7 | 8 | import cv2 9 | import git.repo 10 | from manim import WHITE, Scene 11 | from manim.utils.file_ops import open_file 12 | 13 | from git_sim.settings import settings 14 | from git_sim.enums import VideoFormat 15 | 16 | 17 | def handle_animations(scene: Scene) -> None: 18 | scene.render() 19 | 20 | if settings.video_format == VideoFormat.WEBM: 21 | webm_file_path = str(scene.renderer.file_writer.movie_file_path)[:-3] + "webm" 22 | 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}" 23 | print("Converting video output to .webm format...") 24 | # Start ffmpeg conversion 25 | p = subprocess.Popen(cmd, shell=True) 26 | p.wait() 27 | # if the conversion is successful, delete the .mp4 28 | if os.path.exists(webm_file_path): 29 | os.remove(scene.renderer.file_writer.movie_file_path) 30 | scene.renderer.file_writer.movie_file_path = webm_file_path 31 | 32 | if not settings.animate: 33 | video = cv2.VideoCapture(str(scene.renderer.file_writer.movie_file_path)) 34 | success, image = video.read() 35 | if success: 36 | t = datetime.datetime.fromtimestamp(time.time()).strftime( 37 | "%m-%d-%y_%H-%M-%S" 38 | ) 39 | image_file_name = ( 40 | "git-sim-" 41 | + inspect.stack()[2].function 42 | + "_" 43 | + t 44 | + "." 45 | + settings.img_format 46 | ) 47 | image_file_path = os.path.join( 48 | os.path.join(settings.media_dir, "images"), image_file_name 49 | ) 50 | if settings.transparent_bg: 51 | unsharp_image = cv2.GaussianBlur(image, (0, 0), 3) 52 | image = cv2.addWeighted(image, 1.5, unsharp_image, -0.5, 0) 53 | 54 | tmp = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) 55 | if settings.light_mode: 56 | _, alpha = cv2.threshold(tmp, 225, 255, cv2.THRESH_BINARY_INV) 57 | else: 58 | _, alpha = cv2.threshold(tmp, 25, 255, cv2.THRESH_BINARY) 59 | b, g, r = cv2.split(image) 60 | rgba = [b, g, r, alpha] 61 | image = cv2.merge(rgba, 4) 62 | cv2.imwrite(image_file_path, image) 63 | if ( 64 | not settings.stdout 65 | and not settings.output_only_path 66 | and not settings.quiet 67 | ): 68 | print("Output image location:", image_file_path) 69 | elif ( 70 | not settings.stdout and settings.output_only_path and not settings.quiet 71 | ): 72 | print(image_file_path) 73 | if settings.stdout and not settings.quiet: 74 | sys.stdout.buffer.write(cv2.imencode(".jpg", image)[1].tobytes()) 75 | else: 76 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 77 | print("Output video location:", scene.renderer.file_writer.movie_file_path) 78 | elif not settings.stdout and settings.output_only_path and not settings.quiet: 79 | print(scene.renderer.file_writer.movie_file_path) 80 | 81 | if settings.auto_open and not settings.stdout: 82 | try: 83 | if not settings.animate: 84 | open_file(image_file_path) 85 | else: 86 | open_file(scene.renderer.file_writer.movie_file_path) 87 | except FileNotFoundError: 88 | print( 89 | "Error automatically opening media, please manually open the image or video file to view." 90 | ) 91 | -------------------------------------------------------------------------------- /src/git_sim/branch.py: -------------------------------------------------------------------------------- 1 | import manim as m 2 | 3 | from git_sim.git_sim_base_command import GitSimBaseCommand 4 | from git_sim.settings import settings 5 | 6 | 7 | class Branch(GitSimBaseCommand): 8 | def __init__(self, name: str): 9 | super().__init__() 10 | self.name = name 11 | self.cmd += f"{type(self).__name__.lower()} {self.name}" 12 | 13 | def construct(self): 14 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 15 | print(f"{settings.INFO_STRING} {self.cmd}") 16 | 17 | self.show_intro() 18 | self.parse_commits() 19 | self.parse_all() 20 | self.center_frame_on_commit(self.get_commit()) 21 | 22 | branchText = m.Text( 23 | self.name, 24 | font=self.font, 25 | font_size=20, 26 | color=self.fontColor, 27 | ) 28 | branchRec = m.Rectangle( 29 | color=m.GREEN, 30 | fill_color=m.GREEN, 31 | fill_opacity=0.25, 32 | height=0.4, 33 | width=branchText.width + 0.25, 34 | ) 35 | 36 | branchRec.next_to(self.topref, m.UP) 37 | branchText.move_to(branchRec.get_center()) 38 | 39 | fullbranch = m.VGroup(branchRec, branchText) 40 | 41 | if settings.animate: 42 | self.play(m.Create(fullbranch), run_time=1 / settings.speed) 43 | else: 44 | self.add(fullbranch) 45 | 46 | self.toFadeOut.add(branchRec, branchText) 47 | self.drawnRefs[self.name] = fullbranch 48 | 49 | self.recenter_frame() 50 | self.scale_frame() 51 | self.color_by() 52 | self.show_command_as_title() 53 | self.fadeout() 54 | self.show_outro() 55 | -------------------------------------------------------------------------------- /src/git_sim/checkout.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from argparse import Namespace 3 | 4 | import git 5 | import manim as m 6 | import numpy 7 | 8 | from git_sim.git_sim_base_command import GitSimBaseCommand 9 | from git_sim.settings import settings 10 | 11 | 12 | class Checkout(GitSimBaseCommand): 13 | def __init__(self, branch: str, b: bool): 14 | super().__init__() 15 | self.branch = branch 16 | self.b = b 17 | 18 | if self.b: 19 | if self.branch in self.repo.heads: 20 | print( 21 | "git-sim error: can't create new branch '" 22 | + self.branch 23 | + "', it already exists" 24 | ) 25 | sys.exit(1) 26 | else: 27 | try: 28 | git.repo.fun.rev_parse(self.repo, self.branch) 29 | except git.exc.BadName: 30 | print( 31 | "git-sim error: '" 32 | + self.branch 33 | + "' is not a valid Git ref or identifier." 34 | ) 35 | sys.exit(1) 36 | 37 | if self.branch == self.repo.active_branch.name: 38 | print("git-sim error: already on branch '" + self.branch + "'") 39 | sys.exit(1) 40 | 41 | self.is_ancestor = False 42 | self.is_descendant = False 43 | 44 | # branch being checked out is behind HEAD 45 | if self.repo.active_branch.name in self.repo.git.branch( 46 | "--contains", self.branch 47 | ): 48 | self.is_ancestor = True 49 | # HEAD is behind branch being checked out 50 | elif self.branch in self.repo.git.branch( 51 | "--contains", self.repo.active_branch.name 52 | ): 53 | self.is_descendant = True 54 | 55 | if self.branch in [branch.name for branch in self.repo.heads]: 56 | self.selected_branches.append(self.branch) 57 | 58 | try: 59 | self.selected_branches.append(self.repo.active_branch.name) 60 | except TypeError: 61 | pass 62 | 63 | self.cmd += ( 64 | f"{type(self).__name__.lower()}{' -b' if self.b else ''} {self.branch}" 65 | ) 66 | 67 | def construct(self): 68 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 69 | print(f"{settings.INFO_STRING} {self.cmd}") 70 | 71 | self.show_intro() 72 | head_commit = self.get_commit() 73 | 74 | # using -b flag, create new branch label and exit 75 | if self.b: 76 | self.parse_commits(head_commit) 77 | self.recenter_frame() 78 | self.scale_frame() 79 | self.draw_ref(head_commit, self.topref, text=self.branch, color=m.GREEN) 80 | else: 81 | branch_commit = self.get_commit(self.branch) 82 | 83 | if self.is_ancestor: 84 | commits_in_range = list(self.repo.iter_commits(self.branch + "..HEAD")) 85 | 86 | # branch is reached from HEAD, so draw everything 87 | if len(commits_in_range) <= self.n: 88 | self.parse_commits(head_commit) 89 | reset_head_to = branch_commit.hexsha 90 | self.recenter_frame() 91 | self.scale_frame() 92 | self.reset_head(reset_head_to) 93 | self.reset_branch(head_commit.hexsha) 94 | 95 | # branch is not reached, so start from branch 96 | else: 97 | self.parse_commits(branch_commit) 98 | self.draw_ref(branch_commit, self.topref) 99 | self.recenter_frame() 100 | self.scale_frame() 101 | 102 | elif self.is_descendant: 103 | self.parse_commits(branch_commit) 104 | reset_head_to = branch_commit.hexsha 105 | self.recenter_frame() 106 | self.scale_frame() 107 | if "HEAD" in self.drawnRefs: 108 | self.reset_head(reset_head_to) 109 | self.reset_branch(head_commit.hexsha) 110 | else: 111 | self.draw_ref(branch_commit, self.topref) 112 | else: 113 | self.parse_commits(head_commit) 114 | self.parse_commits(branch_commit, shift=4 * m.DOWN) 115 | self.center_frame_on_commit(branch_commit) 116 | self.recenter_frame() 117 | self.scale_frame() 118 | self.reset_head(branch_commit.hexsha) 119 | self.reset_branch(head_commit.hexsha) 120 | 121 | self.color_by() 122 | self.fadeout() 123 | self.show_command_as_title() 124 | self.show_outro() 125 | -------------------------------------------------------------------------------- /src/git_sim/cherrypick.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import git 4 | import manim as m 5 | 6 | from git_sim.git_sim_base_command import GitSimBaseCommand 7 | from git_sim.settings import settings 8 | 9 | 10 | class CherryPick(GitSimBaseCommand): 11 | def __init__(self, commit: str, edit: str): 12 | super().__init__() 13 | self.commit = commit 14 | self.edit = edit 15 | 16 | try: 17 | git.repo.fun.rev_parse(self.repo, self.commit) 18 | except git.exc.BadName: 19 | print( 20 | "git-sim error: '" 21 | + self.commit 22 | + "' is not a valid Git ref or identifier." 23 | ) 24 | sys.exit(1) 25 | 26 | if self.commit in [branch.name for branch in self.repo.heads]: 27 | self.selected_branches.append(self.commit) 28 | 29 | try: 30 | self.selected_branches.append(self.repo.active_branch.name) 31 | except TypeError: 32 | pass 33 | 34 | self.cmd += f"cherry-pick {self.commit}" + ( 35 | (' -e "' + self.edit + '"') if self.edit else "" 36 | ) 37 | 38 | def construct(self): 39 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 40 | print(f"{settings.INFO_STRING} {self.cmd}") 41 | 42 | if self.repo.active_branch.name in self.repo.git.branch( 43 | "--contains", self.commit 44 | ): 45 | print( 46 | "git-sim error: Commit '" 47 | + self.commit 48 | + "' is already included in the history of active branch '" 49 | + self.repo.active_branch.name 50 | + "'." 51 | ) 52 | sys.exit(1) 53 | 54 | self.show_intro() 55 | head_commit = self.get_commit() 56 | self.parse_commits(head_commit) 57 | cherry_picked_commit = self.get_commit(self.commit) 58 | self.parse_commits(cherry_picked_commit, shift=4 * m.DOWN) 59 | self.parse_all() 60 | self.center_frame_on_commit(head_commit) 61 | self.setup_and_draw_parent( 62 | head_commit, 63 | self.edit if self.edit else cherry_picked_commit.message, 64 | ) 65 | self.draw_arrow_between_commits(cherry_picked_commit.hexsha, "abcdef") 66 | self.recenter_frame() 67 | self.scale_frame() 68 | self.reset_head_branch("abcdef") 69 | self.color_by(offset=2) 70 | self.show_command_as_title() 71 | self.fadeout() 72 | self.show_outro() 73 | -------------------------------------------------------------------------------- /src/git_sim/clean.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | import manim as m 4 | 5 | from typing import List 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Clean(GitSimBaseCommand): 12 | def __init__(self): 13 | super().__init__() 14 | self.hide_first_tag = True 15 | self.allow_no_commits = True 16 | settings.hide_merged_branches = True 17 | self.n = self.n_default 18 | 19 | try: 20 | self.selected_branches.append(self.repo.active_branch.name) 21 | except TypeError: 22 | pass 23 | 24 | self.cmd += f"{type(self).__name__.lower()}" 25 | 26 | def construct(self): 27 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 28 | print(f"{settings.INFO_STRING} {self.cmd}") 29 | 30 | self.show_intro() 31 | self.parse_commits() 32 | self.recenter_frame() 33 | self.scale_frame() 34 | self.vsplit_frame() 35 | self.setup_and_draw_zones( 36 | first_column_name="Untracked files", 37 | second_column_name="----", 38 | third_column_name="Deleted files", 39 | ) 40 | self.show_command_as_title() 41 | self.fadeout() 42 | self.show_outro() 43 | 44 | def create_zone_text( 45 | self, 46 | firstColumnFileNames, 47 | secondColumnFileNames, 48 | thirdColumnFileNames, 49 | firstColumnFiles, 50 | secondColumnFiles, 51 | thirdColumnFiles, 52 | firstColumnFilesDict, 53 | secondColumnFilesDict, 54 | thirdColumnFilesDict, 55 | firstColumnTitle, 56 | secondColumnTitle, 57 | thirdColumnTitle, 58 | horizontal2, 59 | ): 60 | for i, f in enumerate(firstColumnFileNames): 61 | text = ( 62 | m.Text( 63 | self.trim_path(f), 64 | font=self.font, 65 | font_size=24, 66 | color=self.fontColor, 67 | ) 68 | .move_to( 69 | (firstColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 70 | ) 71 | .shift(m.DOWN * 0.5 * (i + 1)) 72 | ) 73 | firstColumnFiles.add(text) 74 | firstColumnFilesDict[f] = text 75 | 76 | for j, f in enumerate(secondColumnFileNames): 77 | text = ( 78 | m.Text( 79 | self.trim_path(f), 80 | font=self.font, 81 | font_size=24, 82 | color=self.fontColor, 83 | ) 84 | .move_to( 85 | (secondColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 86 | ) 87 | .shift(m.DOWN * 0.5 * (j + 1)) 88 | ) 89 | secondColumnFiles.add(text) 90 | secondColumnFilesDict[f] = text 91 | 92 | for h, f in enumerate(thirdColumnFileNames): 93 | text = ( 94 | m.MarkupText( 95 | "" 98 | + self.trim_path(f) 99 | + "", 100 | font=self.font, 101 | font_size=24, 102 | color=self.fontColor, 103 | ) 104 | .move_to( 105 | (thirdColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 106 | ) 107 | .shift(m.DOWN * 0.5 * (h + 1)) 108 | ) 109 | thirdColumnFiles.add(text) 110 | thirdColumnFilesDict[f] = text 111 | 112 | def populate_zones( 113 | self, 114 | firstColumnFileNames, 115 | secondColumnFileNames, 116 | thirdColumnFileNames, 117 | firstColumnArrowMap={}, 118 | secondColumnArrowMap={}, 119 | thirdColumnArrowMap={}, 120 | ): 121 | for z in self.repo.untracked_files: 122 | if "git-sim_media" not in z: 123 | firstColumnFileNames.add(z) 124 | thirdColumnFileNames.add(z) 125 | firstColumnArrowMap[z] = m.Arrow(stroke_width=3, color=self.fontColor) 126 | -------------------------------------------------------------------------------- /src/git_sim/clone.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import Namespace 4 | 5 | import git 6 | import manim as m 7 | import numpy 8 | import tempfile 9 | import shutil 10 | import stat 11 | import re 12 | 13 | from git_sim.git_sim_base_command import GitSimBaseCommand 14 | from git_sim.settings import settings 15 | 16 | 17 | class Clone(GitSimBaseCommand): 18 | # Override since 'clone' subcommand shouldn't require repo to exist 19 | def init_repo(self): 20 | pass 21 | 22 | def __init__(self, url: str, path: str): 23 | super().__init__() 24 | self.url = url 25 | self.path = path 26 | settings.max_branches_per_commit = 2 27 | self.cmd += f"{type(self).__name__.lower()} {self.url + ('' if self.path == '.' else ' ' + self.path)}" 28 | 29 | def construct(self): 30 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 31 | print(f"{settings.INFO_STRING} {self.cmd}") 32 | 33 | self.show_intro() 34 | 35 | # Configure paths to make local clone to run networked commands in 36 | repo_name = re.search(r"/([^/]+)/?$", self.url) 37 | if repo_name: 38 | repo_name = repo_name.group(1) 39 | if repo_name.endswith(".git"): 40 | repo_name = repo_name[:-4] 41 | elif self.url == "." or self.url == "./" or self.url == ".\\": 42 | repo_name = os.path.split(os.getcwd())[1] 43 | else: 44 | print( 45 | f"git-sim error: Invalid repo URL, please confirm repo URL and try again" 46 | ) 47 | sys.exit(1) 48 | 49 | if self.url == os.path.join(self.path, repo_name): 50 | print(f"git-sim error: Cannot clone into same path, please try again") 51 | sys.exit(1) 52 | new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) 53 | 54 | # Create local clone of local repo 55 | try: 56 | self.repo = git.Repo.clone_from(self.url, new_dir, no_hardlinks=True) 57 | except git.GitCommandError as e: 58 | print( 59 | f"git-sim error: Invalid repo URL, please confirm repo URL and try again" 60 | ) 61 | sys.exit(1) 62 | 63 | head_commit = self.get_commit() 64 | self.parse_commits(head_commit) 65 | self.recenter_frame() 66 | self.scale_frame() 67 | self.add_details(repo_name) 68 | self.color_by() 69 | self.show_command_as_title() 70 | self.fadeout() 71 | self.show_outro() 72 | 73 | # Unlink the program from the filesystem 74 | self.repo.git.clear_cache() 75 | 76 | # Delete the local clones 77 | shutil.rmtree(new_dir, onerror=self.del_rw) 78 | 79 | def add_details(self, repo_name): 80 | text1 = m.Text( 81 | f"Successfully cloned from {self.url} into {repo_name if self.path == '.' else self.path}", 82 | font=self.font, 83 | font_size=20, 84 | color=self.fontColor, 85 | weight=m.BOLD, 86 | ) 87 | text1.move_to([self.camera.frame.get_center()[0], 4, 0]) 88 | 89 | text2 = m.Text( 90 | f"Cloned repo log:", 91 | font=self.font, 92 | font_size=20, 93 | color=self.fontColor, 94 | weight=m.BOLD, 95 | ) 96 | text2.move_to(text1.get_center()).shift(m.DOWN / 2) 97 | 98 | self.toFadeOut.add(text1) 99 | self.toFadeOut.add(text2) 100 | self.recenter_frame() 101 | self.scale_frame() 102 | 103 | if settings.animate: 104 | self.play(m.AddTextLetterByLetter(text1), m.AddTextLetterByLetter(text2)) 105 | else: 106 | self.add(text1, text2) 107 | -------------------------------------------------------------------------------- /src/git_sim/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typer 4 | 5 | from typing import List, TYPE_CHECKING 6 | 7 | from git_sim.settings import settings 8 | from git_sim.enums import ResetMode, StashSubCommand, RemoteSubCommand 9 | 10 | if TYPE_CHECKING: 11 | from manim import Scene 12 | 13 | 14 | def handle_animations(scene: Scene) -> None: 15 | from git_sim.animations import handle_animations as _handle_animations 16 | 17 | with settings.font_context: 18 | return _handle_animations(scene) 19 | 20 | 21 | def add( 22 | files: List[str] = typer.Argument( 23 | default=None, 24 | help="The names of one or more files to add to Git's staging area", 25 | ) 26 | ): 27 | from git_sim.add import Add 28 | 29 | settings.hide_first_tag = True 30 | scene = Add(files=files) 31 | handle_animations(scene=scene) 32 | 33 | 34 | def branch( 35 | name: str = typer.Argument( 36 | ..., 37 | help="The name of the new branch", 38 | ) 39 | ): 40 | from git_sim.branch import Branch 41 | 42 | scene = Branch(name=name) 43 | handle_animations(scene=scene) 44 | 45 | 46 | def checkout( 47 | branch: str = typer.Argument( 48 | ..., 49 | help="The name of the branch to checkout", 50 | ), 51 | b: bool = typer.Option( 52 | False, 53 | "-b", 54 | help="Create the specified branch if it doesn't already exist", 55 | ), 56 | ): 57 | from git_sim.checkout import Checkout 58 | 59 | scene = Checkout(branch=branch, b=b) 60 | handle_animations(scene=scene) 61 | 62 | 63 | def cherry_pick( 64 | commit: str = typer.Argument( 65 | ..., 66 | help="The ref (branch/tag), or commit ID to simulate cherry-pick onto active branch", 67 | ), 68 | edit: str = typer.Option( 69 | None, 70 | "--edit", 71 | "-e", 72 | help="Specify a new commit message for the cherry-picked commit", 73 | ), 74 | ): 75 | from git_sim.cherrypick import CherryPick 76 | 77 | scene = CherryPick(commit=commit, edit=edit) 78 | handle_animations(scene=scene) 79 | 80 | 81 | def clean(): 82 | from git_sim.clean import Clean 83 | 84 | settings.hide_first_tag = True 85 | scene = Clean() 86 | handle_animations(scene=scene) 87 | 88 | 89 | def clone( 90 | url: str = typer.Argument( 91 | ..., 92 | help="The web URL or filesystem path of the Git repo to clone", 93 | ), 94 | path: str = typer.Argument( 95 | default=".", 96 | help="The web URL or filesystem path of the Git repo to clone", 97 | ), 98 | ): 99 | from git_sim.clone import Clone 100 | 101 | scene = Clone(url=url, path=path) 102 | handle_animations(scene=scene) 103 | 104 | 105 | def commit( 106 | message: str = typer.Option( 107 | "New commit", 108 | "--message", 109 | "-m", 110 | help="The commit message of the new commit", 111 | ), 112 | amend: bool = typer.Option( 113 | default=False, 114 | help="Amend the last commit message, must be used with the --message flag", 115 | ), 116 | ): 117 | from git_sim.commit import Commit 118 | 119 | settings.hide_first_tag = True 120 | scene = Commit(message=message, amend=amend) 121 | handle_animations(scene=scene) 122 | 123 | 124 | def config( 125 | l: bool = typer.Option( 126 | False, 127 | "-l", 128 | "--list", 129 | help="List existing local repo config settings", 130 | ), 131 | settings: List[str] = typer.Argument( 132 | default=None, 133 | help="The names and values of one or more config settings to set", 134 | ), 135 | ): 136 | from git_sim.config import Config 137 | 138 | scene = Config(l=l, settings=settings) 139 | handle_animations(scene=scene) 140 | 141 | 142 | def fetch( 143 | remote: str = typer.Argument( 144 | default=None, 145 | help="The name of the remote to fetch from", 146 | ), 147 | branch: str = typer.Argument( 148 | default=None, 149 | help="The name of the branch to fetch", 150 | ), 151 | ): 152 | from git_sim.fetch import Fetch 153 | 154 | scene = Fetch(remote=remote, branch=branch) 155 | handle_animations(scene=scene) 156 | 157 | 158 | def init(): 159 | from git_sim.init import Init 160 | 161 | scene = Init() 162 | handle_animations(scene=scene) 163 | 164 | 165 | def log( 166 | ctx: typer.Context, 167 | n: int = typer.Option( 168 | None, 169 | "-n", 170 | help="Number of commits to display from branch heads", 171 | ), 172 | all: bool = typer.Option( 173 | False, 174 | "--all", 175 | help="Display all local branches in the log output", 176 | ), 177 | ): 178 | from git_sim.log import Log 179 | 180 | scene = Log(ctx=ctx, n=n, all=all) 181 | handle_animations(scene=scene) 182 | 183 | 184 | def merge( 185 | branch: str = typer.Argument( 186 | ..., 187 | help="The name of the branch to merge into the active checked-out branch", 188 | ), 189 | no_ff: bool = typer.Option( 190 | False, 191 | "--no-ff", 192 | help="Simulate creation of a merge commit in all cases, even when the merge could instead be resolved as a fast-forward", 193 | ), 194 | message: str = typer.Option( 195 | "Merge commit", 196 | "--message", 197 | "-m", 198 | help="The commit message of the new merge commit", 199 | ), 200 | ): 201 | from git_sim.merge import Merge 202 | 203 | scene = Merge(branch=branch, no_ff=no_ff, message=message) 204 | handle_animations(scene=scene) 205 | 206 | 207 | def mv( 208 | file: str = typer.Argument( 209 | default=None, 210 | help="The name of the file to change the name/path of", 211 | ), 212 | new_file: str = typer.Argument( 213 | default=None, 214 | help="The new name/path of the file", 215 | ), 216 | ): 217 | from git_sim.mv import Mv 218 | 219 | settings.hide_first_tag = True 220 | scene = Mv(file=file, new_file=new_file) 221 | handle_animations(scene=scene) 222 | 223 | 224 | def pull( 225 | remote: str = typer.Argument( 226 | default=None, 227 | help="The name of the remote to pull from", 228 | ), 229 | branch: str = typer.Argument( 230 | default=None, 231 | help="The name of the branch to pull", 232 | ), 233 | ): 234 | from git_sim.pull import Pull 235 | 236 | scene = Pull(remote=remote, branch=branch) 237 | handle_animations(scene=scene) 238 | 239 | 240 | def push( 241 | remote: str = typer.Argument( 242 | default=None, 243 | help="The name of the remote to push to", 244 | ), 245 | branch: str = typer.Argument( 246 | default=None, 247 | help="The name of the branch to push", 248 | ), 249 | set_upstream: bool = typer.Option( 250 | False, 251 | "--set-upstream", 252 | help="Map the local branch to the specified upstream branch", 253 | ), 254 | ): 255 | from git_sim.push import Push 256 | 257 | scene = Push(remote=remote, branch=branch, set_upstream=set_upstream) 258 | handle_animations(scene=scene) 259 | 260 | 261 | def rebase( 262 | branch: str = typer.Argument( 263 | ..., 264 | help="The branch to simulate rebasing the checked-out commit onto", 265 | ) 266 | ): 267 | from git_sim.rebase import Rebase 268 | 269 | scene = Rebase(branch=branch) 270 | handle_animations(scene=scene) 271 | 272 | 273 | def remote( 274 | command: RemoteSubCommand = typer.Argument( 275 | default=None, 276 | help="Remote subcommand (add, rename, remove, get-url, set-url)", 277 | ), 278 | remote: str = typer.Argument( 279 | default=None, 280 | help="The name of the remote", 281 | ), 282 | url_or_path: str = typer.Argument( 283 | default=None, 284 | help="The url or path to the remote", 285 | ), 286 | ): 287 | from git_sim.remote import Remote 288 | 289 | scene = Remote(command=command, remote=remote, url_or_path=url_or_path) 290 | handle_animations(scene=scene) 291 | 292 | 293 | def reset( 294 | commit: str = typer.Argument( 295 | default="HEAD", 296 | help="The ref (branch/tag), or commit ID to simulate reset to", 297 | ), 298 | mode: ResetMode = typer.Option( 299 | default="mixed", 300 | help="Either mixed, soft, or hard", 301 | ), 302 | soft: bool = typer.Option( 303 | default=False, 304 | help="Simulate a soft reset, shortcut for --mode=soft", 305 | ), 306 | mixed: bool = typer.Option( 307 | default=False, 308 | help="Simulate a mixed reset, shortcut for --mode=mixed", 309 | ), 310 | hard: bool = typer.Option( 311 | default=False, 312 | help="Simulate a soft reset, shortcut for --mode=hard", 313 | ), 314 | ): 315 | from git_sim.reset import Reset 316 | 317 | settings.hide_first_tag = True 318 | scene = Reset(commit=commit, mode=mode, soft=soft, mixed=mixed, hard=hard) 319 | handle_animations(scene=scene) 320 | 321 | 322 | def restore( 323 | files: List[str] = typer.Argument( 324 | default=None, 325 | help="The names of one or more files to restore", 326 | ), 327 | staged: bool = typer.Option( 328 | False, 329 | "--staged", 330 | help="Restore staged file to working directory", 331 | ), 332 | ): 333 | from git_sim.restore import Restore 334 | 335 | settings.hide_first_tag = True 336 | scene = Restore(files=files, staged=staged) 337 | handle_animations(scene=scene) 338 | 339 | 340 | def revert( 341 | commit: str = typer.Argument( 342 | default="HEAD", 343 | help="The ref (branch/tag), or commit ID to simulate revert", 344 | ) 345 | ): 346 | from git_sim.revert import Revert 347 | 348 | settings.hide_first_tag = True 349 | scene = Revert(commit=commit) 350 | handle_animations(scene=scene) 351 | 352 | 353 | def rm( 354 | files: List[str] = typer.Argument( 355 | default=None, 356 | help="The names of one or more files to remove from Git's index", 357 | ) 358 | ): 359 | from git_sim.rm import Rm 360 | 361 | settings.hide_first_tag = True 362 | scene = Rm(files=files) 363 | handle_animations(scene=scene) 364 | 365 | 366 | def stash( 367 | command: StashSubCommand = typer.Argument( 368 | default=None, 369 | help="Stash subcommand (push, pop, apply)", 370 | ), 371 | files: List[str] = typer.Argument( 372 | default=None, 373 | help="The name of the file to stash changes for", 374 | ), 375 | stash_index: str = typer.Argument( 376 | default="0", 377 | help="Stash index", 378 | ), 379 | ): 380 | from git_sim.stash import Stash 381 | 382 | settings.hide_first_tag = True 383 | scene = Stash(files=files, command=command, stash_index=stash_index) 384 | handle_animations(scene=scene) 385 | 386 | 387 | def status(): 388 | from git_sim.status import Status 389 | 390 | settings.hide_first_tag = True 391 | settings.allow_no_commits = True 392 | 393 | scene = Status() 394 | handle_animations(scene=scene) 395 | 396 | 397 | def switch( 398 | branch: str = typer.Argument( 399 | ..., 400 | help="The name of the branch to switch to", 401 | ), 402 | c: bool = typer.Option( 403 | False, 404 | "-c", 405 | help="Create the specified branch if it doesn't already exist", 406 | ), 407 | detach: bool = typer.Option( 408 | False, 409 | "--detach", 410 | help="Allow switch resulting in detached HEAD state", 411 | ), 412 | ): 413 | from git_sim.switch import Switch 414 | 415 | scene = Switch(branch=branch, c=c, detach=detach) 416 | handle_animations(scene=scene) 417 | 418 | 419 | def tag( 420 | name: str = typer.Argument( 421 | ..., 422 | help="The name of the tag", 423 | ), 424 | commit: str = typer.Argument( 425 | default=None, 426 | help="The commit to tag", 427 | ), 428 | d: bool = typer.Option( 429 | False, 430 | "-d", 431 | help="Delete the specified tag", 432 | ), 433 | ): 434 | from git_sim.tag import Tag 435 | 436 | scene = Tag(name=name, commit=commit, d=d) 437 | handle_animations(scene=scene) 438 | -------------------------------------------------------------------------------- /src/git_sim/commit.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import git 4 | import manim as m 5 | 6 | from git_sim.git_sim_base_command import GitSimBaseCommand 7 | from git_sim.settings import settings 8 | 9 | 10 | class Commit(GitSimBaseCommand): 11 | def __init__(self, message: str, amend: bool): 12 | super().__init__() 13 | self.message = message 14 | self.amend = amend 15 | 16 | self.n_default = 4 if not self.amend else 5 17 | self.n = self.n_default 18 | 19 | self.hide_first_tag = True 20 | settings.hide_merged_branches = True 21 | 22 | try: 23 | self.selected_branches.append(self.repo.active_branch.name) 24 | except TypeError: 25 | pass 26 | 27 | if self.amend and self.message == "New commit": 28 | print( 29 | "git-sim error: The --amend flag must be used with the -m flag to specify the amended commit message." 30 | ) 31 | sys.exit(1) 32 | 33 | self.cmd += ( 34 | f"{type(self).__name__.lower()} {'--amend ' if self.amend else ''}" 35 | + '-m "' 36 | + self.message 37 | + '"' 38 | ) 39 | 40 | def construct(self): 41 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 42 | print(f"{settings.INFO_STRING} {self.cmd}") 43 | 44 | self.show_intro() 45 | head_commit = self.get_commit() 46 | 47 | if self.amend: 48 | tree = self.repo.tree() 49 | amended = git.Commit.create_from_tree( 50 | self.repo, 51 | tree, 52 | self.message, 53 | ) 54 | head_commit = amended 55 | 56 | self.parse_commits(head_commit) 57 | self.center_frame_on_commit(head_commit) 58 | 59 | if not self.amend: 60 | self.setup_and_draw_parent(head_commit, self.message) 61 | else: 62 | self.draw_ref(head_commit, self.drawnCommitIds[amended.hexsha]) 63 | self.draw_ref( 64 | head_commit, 65 | self.drawnRefs["HEAD"], 66 | text=self.repo.active_branch.name, 67 | color=m.GREEN, 68 | ) 69 | 70 | self.recenter_frame() 71 | self.scale_frame() 72 | 73 | if not self.amend: 74 | self.reset_head_branch("abcdef") 75 | self.vsplit_frame() 76 | self.setup_and_draw_zones( 77 | first_column_name="Working directory", 78 | second_column_name="Staged files", 79 | third_column_name="New commit", 80 | ) 81 | 82 | self.show_command_as_title() 83 | self.fadeout() 84 | self.show_outro() 85 | 86 | def populate_zones( 87 | self, 88 | firstColumnFileNames, 89 | secondColumnFileNames, 90 | thirdColumnFileNames, 91 | firstColumnArrowMap={}, 92 | secondColumnArrowMap={}, 93 | thirdColumnArrowMap={}, 94 | ): 95 | for x in self.repo.index.diff(None): 96 | if "git-sim_media" not in x.a_path: 97 | firstColumnFileNames.add(x.a_path) 98 | 99 | if self.head_exists(): 100 | for y in self.repo.index.diff("HEAD"): 101 | if "git-sim_media" not in y.a_path: 102 | secondColumnFileNames.add(y.a_path) 103 | thirdColumnFileNames.add(y.a_path) 104 | secondColumnArrowMap[y.a_path] = m.Arrow( 105 | stroke_width=3, color=self.fontColor 106 | ) 107 | else: 108 | for y in self.repo.index.diff(None, staged=True): 109 | if "git-sim_media" not in y.a_path: 110 | secondColumnFileNames.add(y.a_path) 111 | thirdColumnFileNames.add(y.a_path) 112 | secondColumnArrowMap[y.a_path] = m.Arrow( 113 | stroke_width=3, color=self.fontColor 114 | ) 115 | -------------------------------------------------------------------------------- /src/git_sim/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import git 4 | import sys 5 | import stat 6 | import numpy 7 | import shutil 8 | import tempfile 9 | 10 | import manim as m 11 | 12 | from typing import List 13 | from git.repo import Repo 14 | from argparse import Namespace 15 | from configparser import NoSectionError 16 | from git.exc import GitCommandError, InvalidGitRepositoryError 17 | 18 | from git_sim.settings import settings 19 | from git_sim.git_sim_base_command import GitSimBaseCommand 20 | 21 | 22 | class Config(GitSimBaseCommand): 23 | def __init__(self, l: bool, settings: List[str]): 24 | super().__init__() 25 | self.l = l 26 | self.settings = settings 27 | self.time_per_char = 0.05 28 | 29 | for i, setting in enumerate(self.settings): 30 | if " " in setting: 31 | self.settings[i] = f'"{setting}"' 32 | 33 | if self.l: 34 | self.cmd += f"{type(self).__name__.lower()} {'--list'}" 35 | else: 36 | self.cmd += f"{type(self).__name__.lower()} {' '.join(self.settings)}" 37 | 38 | def construct(self): 39 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 40 | print(f"{settings.INFO_STRING} {self.cmd}") 41 | 42 | self.show_intro() 43 | self.add_details() 44 | self.recenter_frame() 45 | self.scale_frame() 46 | self.fadeout() 47 | self.show_outro() 48 | 49 | def add_details(self): 50 | down_shift = m.DOWN * 0.5 51 | project_root = m.Rectangle( 52 | height=9.0, 53 | width=18.0, 54 | color=self.fontColor, 55 | ).move_to((0, 1000, 0)) 56 | self.camera.frame.scale_to_fit_width(18 * 1.1) 57 | self.camera.frame.move_to(project_root.get_center()) 58 | 59 | cmd_text = m.Text( 60 | self.trim_cmd(self.cmd, 50), 61 | font=self.font, 62 | font_size=36, 63 | color=self.fontColor, 64 | ) 65 | cmd_text.align_to(project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) 66 | 67 | project_root_text = m.Text( 68 | os.path.basename(os.getcwd()) + "/", 69 | font=self.font, 70 | font_size=20, 71 | color=self.fontColor, 72 | ) 73 | project_root_text.align_to(project_root, m.LEFT).align_to( 74 | project_root, m.UP 75 | ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) 76 | 77 | dot_git_text = m.Text( 78 | ".git/", 79 | font=self.font, 80 | font_size=20, 81 | color=self.fontColor, 82 | ) 83 | dot_git_text.align_to(project_root_text, m.UP).shift(down_shift).align_to( 84 | project_root_text, m.LEFT 85 | ).shift(m.RIGHT * 0.5) 86 | 87 | config_text = m.Text( 88 | "config", 89 | font=self.font, 90 | font_size=20, 91 | color=self.fontColor, 92 | ) 93 | config_text.align_to(dot_git_text, m.UP).shift(down_shift).align_to( 94 | dot_git_text, m.LEFT 95 | ).shift(m.RIGHT * 0.5) 96 | 97 | if settings.animate: 98 | if settings.show_command_as_title: 99 | self.play( 100 | m.AddTextLetterByLetter(cmd_text, time_per_char=self.time_per_char) 101 | ) 102 | self.play(m.Create(project_root, time_per_char=self.time_per_char)) 103 | self.play( 104 | m.AddTextLetterByLetter( 105 | project_root_text, time_per_char=self.time_per_char 106 | ) 107 | ) 108 | self.play( 109 | m.AddTextLetterByLetter(dot_git_text, time_per_char=self.time_per_char) 110 | ) 111 | self.play( 112 | m.AddTextLetterByLetter(config_text, time_per_char=self.time_per_char) 113 | ) 114 | else: 115 | if settings.show_command_as_title: 116 | self.add(cmd_text) 117 | self.add(project_root) 118 | self.add(project_root_text) 119 | self.add(dot_git_text) 120 | self.add(config_text) 121 | 122 | config = self.repo.config_reader() 123 | if self.l: 124 | last_element = config_text 125 | for i, section in enumerate(config.sections()): 126 | section_text = ( 127 | m.Text( 128 | f"[{section}]", 129 | font=self.font, 130 | color=self.fontColor, 131 | font_size=20, 132 | ) 133 | .align_to(last_element, m.UP) 134 | .shift(down_shift) 135 | .align_to(config_text, m.LEFT) 136 | .shift(m.RIGHT * 0.5) 137 | ) 138 | self.toFadeOut.add(section_text) 139 | if settings.animate: 140 | self.play( 141 | m.AddTextLetterByLetter( 142 | section_text, time_per_char=self.time_per_char 143 | ) 144 | ) 145 | else: 146 | self.add(section_text) 147 | last_element = section_text 148 | project_root = self.resize_rectangle(project_root, last_element) 149 | for j, option in enumerate(config.options(section)): 150 | if option != "__name__": 151 | option_text = ( 152 | m.Text( 153 | f"{option} = {config.get_value(section, option)}", 154 | font=self.font, 155 | color=self.fontColor, 156 | font_size=20, 157 | ) 158 | .align_to(last_element, m.UP) 159 | .shift(down_shift) 160 | .align_to(section_text, m.LEFT) 161 | .shift(m.RIGHT * 0.5) 162 | ) 163 | self.toFadeOut.add(option_text) 164 | last_element = option_text 165 | if settings.animate: 166 | self.play( 167 | m.AddTextLetterByLetter( 168 | option_text, time_per_char=self.time_per_char 169 | ) 170 | ) 171 | else: 172 | self.add(option_text) 173 | if not ( 174 | i == len(config.sections()) - 1 175 | and j == len(config.options(section)) - 1 176 | ): 177 | project_root = self.resize_rectangle( 178 | project_root, last_element 179 | ) 180 | else: 181 | if not self.settings: 182 | print("git-sim error: no config option specified") 183 | sys.exit(1) 184 | elif len(self.settings) > 2: 185 | print("git-sim error: too many config options specified") 186 | sys.exit(1) 187 | elif "." not in self.settings[0]: 188 | print("git-sim error: specify config option as 'section.option'") 189 | sys.exit(1) 190 | section = self.settings[0][: self.settings[0].index(".")] 191 | option = self.settings[0][self.settings[0].index(".") + 1 :] 192 | if len(self.settings) == 1: 193 | try: 194 | value = config.get_value(section, option) 195 | except NoSectionError: 196 | print(f"git-sim error: section '{section}' doesn't exist in config") 197 | sys.exit(1) 198 | elif len(self.settings) == 2: 199 | value = self.settings[1].strip('"').strip("'").strip("\\") 200 | section_text = ( 201 | m.Text( 202 | f"[{self.trim_cmd(section, 50)}]", 203 | font=self.font, 204 | color=self.fontColor, 205 | font_size=20, 206 | weight=m.BOLD, 207 | ) 208 | .align_to(config_text, m.UP) 209 | .shift(down_shift) 210 | .align_to(config_text, m.LEFT) 211 | .shift(m.RIGHT * 0.5) 212 | ) 213 | option_text = ( 214 | m.Text( 215 | f"{self.trim_cmd(option, 40)} = {self.trim_cmd(value, 40)}", 216 | font=self.font, 217 | color=self.fontColor, 218 | font_size=20, 219 | weight=m.BOLD, 220 | ) 221 | .align_to(section_text, m.UP) 222 | .shift(down_shift) 223 | .align_to(section_text, m.LEFT) 224 | .shift(m.RIGHT * 0.5) 225 | ) 226 | self.toFadeOut.add(section_text) 227 | self.toFadeOut.add(option_text) 228 | if settings.animate: 229 | self.play( 230 | m.AddTextLetterByLetter( 231 | section_text, time_per_char=self.time_per_char 232 | ) 233 | ) 234 | self.play( 235 | m.AddTextLetterByLetter( 236 | option_text, time_per_char=self.time_per_char 237 | ) 238 | ) 239 | else: 240 | self.add(section_text) 241 | self.add(option_text) 242 | 243 | if settings.show_command_as_title: 244 | self.toFadeOut.add(cmd_text) 245 | self.toFadeOut.add(project_root) 246 | self.toFadeOut.add(project_root_text) 247 | self.toFadeOut.add(dot_git_text) 248 | self.toFadeOut.add(config_text) 249 | 250 | def resize_rectangle(self, rect, last_element): 251 | if ( 252 | last_element.get_bottom()[1] - 3 * last_element.height 253 | > rect.get_bottom()[1] 254 | ): 255 | return rect 256 | new_rect = m.Rectangle( 257 | width=rect.width, 258 | height=rect.height + 2 * last_element.height, 259 | color=rect.color, 260 | ) 261 | new_rect.align_to(rect, m.UP) 262 | self.toFadeOut.remove(rect) 263 | self.toFadeOut.add(new_rect) 264 | if settings.animate: 265 | self.recenter_frame() 266 | self.scale_frame() 267 | self.play(m.ReplacementTransform(rect, new_rect)) 268 | else: 269 | self.remove(rect) 270 | self.add(new_rect) 271 | return new_rect 272 | -------------------------------------------------------------------------------- /src/git_sim/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ResetMode(Enum): 5 | DEFAULT = "mixed" 6 | SOFT = "soft" 7 | MIXED = "mixed" 8 | HARD = "hard" 9 | 10 | 11 | class ColorByOptions(Enum): 12 | AUTHOR = "author" 13 | BRANCH = "branch" 14 | NOTLOCAL1 = "notlocal1" 15 | NOTLOCAL2 = "notlocal2" 16 | 17 | 18 | class StyleOptions(Enum): 19 | CLEAN = "clean" 20 | THICK = "thick" 21 | 22 | 23 | class VideoFormat(str, Enum): 24 | MP4 = "mp4" 25 | WEBM = "webm" 26 | 27 | 28 | class ImgFormat(str, Enum): 29 | JPG = "jpg" 30 | PNG = "png" 31 | 32 | 33 | class StashSubCommand(Enum): 34 | POP = "pop" 35 | APPLY = "apply" 36 | PUSH = "push" 37 | 38 | 39 | class RemoteSubCommand(Enum): 40 | ADD = "add" 41 | RENAME = "rename" 42 | REMOVE = "remove" 43 | GET_URL = "get-url" 44 | SET_URL = "set-url" 45 | -------------------------------------------------------------------------------- /src/git_sim/fetch.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import Namespace 4 | 5 | import git 6 | import manim as m 7 | import numpy 8 | import tempfile 9 | import shutil 10 | import stat 11 | 12 | from git_sim.git_sim_base_command import GitSimBaseCommand 13 | from git_sim.settings import settings 14 | 15 | 16 | class Fetch(GitSimBaseCommand): 17 | def __init__(self, remote: str, branch: str): 18 | super().__init__() 19 | self.remote = remote 20 | self.branch = branch 21 | settings.max_branches_per_commit = 2 22 | 23 | if self.remote and self.remote not in self.repo.remotes: 24 | print("git-sim error: no remote with name '" + self.remote + "'") 25 | sys.exit(1) 26 | 27 | self.cmd += f"{type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" 28 | 29 | def construct(self): 30 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 31 | print(f"{settings.INFO_STRING} {self.cmd}") 32 | 33 | if not self.remote: 34 | self.remote = "origin" 35 | if not self.branch: 36 | self.branch = self.repo.active_branch.name 37 | 38 | self.show_intro() 39 | 40 | git_root = self.repo.git.rev_parse("--show-toplevel") 41 | repo_name = os.path.basename(self.repo.working_dir) 42 | new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) 43 | 44 | orig_remotes = self.repo.remotes 45 | self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) 46 | for r1 in orig_remotes: 47 | for r2 in self.repo.remotes: 48 | if r1.name == r2.name: 49 | r2.set_url(r1.url) 50 | 51 | try: 52 | self.repo.git.fetch(self.remote, self.branch) 53 | except git.GitCommandError as e: 54 | print(e) 55 | sys.exit(1) 56 | 57 | # local branch doesn't exist 58 | if self.branch not in self.repo.heads: 59 | start_parse_from_remote = True 60 | # fetched branch is ahead of local branch 61 | elif (self.remote + "/" + self.branch) in self.repo.git.branch( 62 | "-r", "--contains", self.branch 63 | ): 64 | start_parse_from_remote = True 65 | # fetched branch is behind local branch 66 | elif self.branch in self.repo.git.branch( 67 | "--contains", (self.remote + "/" + self.branch) 68 | ): 69 | start_parse_from_remote = False 70 | else: 71 | start_parse_from_remote = True 72 | 73 | if start_parse_from_remote: 74 | commit = self.get_commit(self.remote + "/" + self.branch) 75 | else: 76 | commit = self.get_commit(self.branch) 77 | self.parse_commits(commit) 78 | 79 | self.recenter_frame() 80 | self.scale_frame() 81 | self.color_by() 82 | self.show_command_as_title() 83 | self.fadeout() 84 | self.show_outro() 85 | self.repo.git.clear_cache() 86 | shutil.rmtree(new_dir, onerror=self.del_rw) 87 | -------------------------------------------------------------------------------- /src/git_sim/init.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import Namespace 4 | 5 | import git 6 | import manim as m 7 | import numpy 8 | import tempfile 9 | import shutil 10 | import stat 11 | import re 12 | 13 | from git.exc import GitCommandError, InvalidGitRepositoryError 14 | from git.repo import Repo 15 | 16 | from git_sim.git_sim_base_command import GitSimBaseCommand 17 | from git_sim.settings import settings 18 | 19 | 20 | class Init(GitSimBaseCommand): 21 | def __init__(self): 22 | super().__init__() 23 | self.cmd += f"{type(self).__name__.lower()}" 24 | 25 | def init_repo(self): 26 | pass 27 | 28 | def construct(self): 29 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 30 | print(f"{settings.INFO_STRING} {self.cmd}") 31 | 32 | self.show_intro() 33 | self.add_details() 34 | self.recenter_frame() 35 | self.scale_frame() 36 | self.fadeout() 37 | self.show_outro() 38 | 39 | def add_details(self): 40 | self.camera.frame.scale_to_fit_width(18 * 1.1) 41 | project_root = m.Rectangle( 42 | height=9.0, 43 | width=18.0, 44 | color=self.fontColor, 45 | ) 46 | 47 | cmd_text = m.Text( 48 | self.cmd, 49 | font=self.font, 50 | font_size=36, 51 | color=self.fontColor, 52 | ) 53 | cmd_text.align_to(project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) 54 | 55 | project_root_text = m.Text( 56 | os.path.basename(os.getcwd()) + "/", 57 | font=self.font, 58 | font_size=20, 59 | color=self.fontColor, 60 | ) 61 | project_root_text.align_to(project_root, m.LEFT).align_to( 62 | project_root, m.UP 63 | ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) 64 | 65 | dot_git_text = m.Text( 66 | ".git/", 67 | font=self.font, 68 | font_size=20, 69 | color=self.fontColor, 70 | ) 71 | dot_git_text.align_to(project_root_text, m.UP).shift(m.DOWN).align_to( 72 | project_root_text, m.LEFT 73 | ).shift(m.RIGHT * 0.5) 74 | 75 | head_text = ( 76 | m.Text("HEAD", font=self.font, color=self.fontColor, font_size=20) 77 | .align_to(dot_git_text, m.UP) 78 | .shift(m.DOWN) 79 | .align_to(dot_git_text, m.LEFT) 80 | .shift(m.RIGHT * 0.5) 81 | ) 82 | 83 | down_shift = m.DOWN 84 | config_text = ( 85 | m.Text("config", font=self.font, color=self.fontColor, font_size=20) 86 | .align_to(head_text, m.UP) 87 | .shift(down_shift) 88 | .align_to(dot_git_text, m.LEFT) 89 | .shift(m.RIGHT * 0.5) 90 | ) 91 | description_text = ( 92 | m.Text("description", font=self.font, color=self.fontColor, font_size=20) 93 | .align_to(config_text, m.UP) 94 | .shift(down_shift) 95 | .align_to(dot_git_text, m.LEFT) 96 | .shift(m.RIGHT * 0.5) 97 | ) 98 | hooks_text = ( 99 | m.Text("hooks/", font=self.font, color=self.fontColor, font_size=20) 100 | .align_to(description_text, m.UP) 101 | .shift(down_shift) 102 | .align_to(dot_git_text, m.LEFT) 103 | .shift(m.RIGHT * 0.5) 104 | ) 105 | info_text = ( 106 | m.Text("info/", font=self.font, color=self.fontColor, font_size=20) 107 | .align_to(hooks_text, m.UP) 108 | .shift(down_shift) 109 | .align_to(dot_git_text, m.LEFT) 110 | .shift(m.RIGHT * 0.5) 111 | ) 112 | objects_text = ( 113 | m.Text("objects/", font=self.font, color=self.fontColor, font_size=20) 114 | .align_to(info_text, m.UP) 115 | .shift(down_shift) 116 | .align_to(dot_git_text, m.LEFT) 117 | .shift(m.RIGHT * 0.5) 118 | ) 119 | refs_text = ( 120 | m.Text("refs/", font=self.font, color=self.fontColor, font_size=20) 121 | .align_to(objects_text, m.UP) 122 | .shift(down_shift) 123 | .align_to(dot_git_text, m.LEFT) 124 | .shift(m.RIGHT * 0.5) 125 | ) 126 | 127 | dot_git_text_arrow = m.Arrow( 128 | start=dot_git_text.get_right(), 129 | end=dot_git_text.get_right() + m.RIGHT * 3.5, 130 | color=self.fontColor, 131 | ) 132 | head_text_arrow = m.Arrow( 133 | start=head_text.get_right(), 134 | end=(dot_git_text_arrow.end[0], head_text.get_right()[1], 0), 135 | color=self.fontColor, 136 | ) 137 | config_text_arrow = m.Arrow( 138 | start=config_text.get_right(), 139 | end=(dot_git_text_arrow.end[0], config_text.get_right()[1], 0), 140 | color=self.fontColor, 141 | ) 142 | description_text_arrow = m.Arrow( 143 | start=description_text.get_right(), 144 | end=(dot_git_text_arrow.end[0], description_text.get_right()[1], 0), 145 | color=self.fontColor, 146 | ) 147 | hooks_text_arrow = m.Arrow( 148 | start=hooks_text.get_right(), 149 | end=(dot_git_text_arrow.end[0], hooks_text.get_right()[1], 0), 150 | color=self.fontColor, 151 | ) 152 | info_text_arrow = m.Arrow( 153 | start=info_text.get_right(), 154 | end=(dot_git_text_arrow.end[0], info_text.get_right()[1], 0), 155 | color=self.fontColor, 156 | ) 157 | objects_text_arrow = m.Arrow( 158 | start=objects_text.get_right(), 159 | end=(dot_git_text_arrow.end[0], objects_text.get_right()[1], 0), 160 | color=self.fontColor, 161 | ) 162 | refs_text_arrow = m.Arrow( 163 | start=refs_text.get_right(), 164 | end=(dot_git_text_arrow.end[0], refs_text.get_right()[1], 0), 165 | color=self.fontColor, 166 | ) 167 | 168 | dot_git_desc = m.Text( 169 | "The hidden .git/ folder is created after running the 'git init' command.", 170 | font=self.font, 171 | font_size=18, 172 | color=self.fontColor, 173 | ).next_to(dot_git_text_arrow, m.RIGHT) 174 | head_desc = m.Text( 175 | "A label (ref) that points to the currently checked-out commit.", 176 | font=self.font, 177 | font_size=18, 178 | color=self.fontColor, 179 | ).next_to(head_text_arrow, m.RIGHT) 180 | config_desc = m.Text( 181 | "A file containing Git configuration settings for the local repo.", 182 | font=self.font, 183 | font_size=18, 184 | color=self.fontColor, 185 | ).next_to(config_text_arrow, m.RIGHT) 186 | description_desc = m.Text( 187 | "A file containing an optional description for your Git repo.", 188 | font=self.font, 189 | font_size=18, 190 | color=self.fontColor, 191 | ).next_to(description_text_arrow, m.RIGHT) 192 | hooks_desc = m.Text( 193 | "A folder containing 'hooks' which allow triggering custom\nscripts after running Git actions.", 194 | font=self.font, 195 | font_size=18, 196 | color=self.fontColor, 197 | ).next_to(hooks_text_arrow, m.RIGHT) 198 | info_desc = m.Text( 199 | "A folder containing the 'exclude' file, tells Git to ignore\nspecific file patterns on your system.", 200 | font=self.font, 201 | font_size=18, 202 | color=self.fontColor, 203 | ).next_to(info_text_arrow, m.RIGHT) 204 | objects_desc = m.Text( 205 | "A folder containing Git's object database, which stores the\nobjects representing code files, changes and commits tracked by Git.", 206 | font=self.font, 207 | font_size=18, 208 | color=self.fontColor, 209 | ).next_to(objects_text_arrow, m.RIGHT) 210 | refs_desc = m.Text( 211 | "A folder holding the refs (labels) Git uses to represent branches & tags.", 212 | font=self.font, 213 | font_size=18, 214 | color=self.fontColor, 215 | ).next_to(refs_text_arrow, m.RIGHT) 216 | 217 | if settings.animate: 218 | if settings.show_command_as_title: 219 | self.play(m.AddTextLetterByLetter(cmd_text)) 220 | self.play(m.Create(project_root)) 221 | self.play(m.AddTextLetterByLetter(project_root_text)) 222 | self.play( 223 | m.AddTextLetterByLetter(dot_git_text), 224 | m.Create(dot_git_text_arrow), 225 | m.AddTextLetterByLetter(dot_git_desc), 226 | ) 227 | self.play( 228 | m.AddTextLetterByLetter(head_text), 229 | m.Create(head_text_arrow), 230 | m.AddTextLetterByLetter(head_desc), 231 | ) 232 | self.play( 233 | m.AddTextLetterByLetter(config_text), 234 | m.Create(config_text_arrow), 235 | m.AddTextLetterByLetter(config_desc), 236 | ) 237 | self.play( 238 | m.AddTextLetterByLetter(description_text), 239 | m.Create(description_text_arrow), 240 | m.AddTextLetterByLetter(description_desc), 241 | ) 242 | self.play( 243 | m.AddTextLetterByLetter(hooks_text), 244 | m.Create(hooks_text_arrow), 245 | m.AddTextLetterByLetter(hooks_desc), 246 | ) 247 | self.play( 248 | m.AddTextLetterByLetter(info_text), 249 | m.Create(info_text_arrow), 250 | m.AddTextLetterByLetter(info_desc), 251 | ) 252 | self.play( 253 | m.AddTextLetterByLetter(objects_text), 254 | m.Create(objects_text_arrow), 255 | m.AddTextLetterByLetter(objects_desc), 256 | ) 257 | self.play( 258 | m.AddTextLetterByLetter(refs_text), 259 | m.Create(refs_text_arrow), 260 | m.AddTextLetterByLetter(refs_desc), 261 | ) 262 | else: 263 | if settings.show_command_as_title: 264 | self.add(cmd_text) 265 | self.add(project_root) 266 | self.add(project_root_text) 267 | self.add(dot_git_text) 268 | self.add( 269 | head_text, 270 | config_text, 271 | description_text, 272 | hooks_text, 273 | info_text, 274 | objects_text, 275 | refs_text, 276 | ) 277 | self.add( 278 | dot_git_text_arrow, 279 | head_text_arrow, 280 | config_text_arrow, 281 | description_text_arrow, 282 | hooks_text_arrow, 283 | info_text_arrow, 284 | objects_text_arrow, 285 | refs_text_arrow, 286 | ) 287 | self.add( 288 | dot_git_desc, 289 | head_desc, 290 | config_desc, 291 | description_desc, 292 | hooks_desc, 293 | info_desc, 294 | objects_desc, 295 | refs_desc, 296 | ) 297 | 298 | if settings.show_command_as_title: 299 | self.toFadeOut.add(cmd_text) 300 | self.toFadeOut.add(project_root) 301 | self.toFadeOut.add(project_root_text) 302 | self.toFadeOut.add( 303 | head_text, 304 | config_text, 305 | description_text, 306 | hooks_text, 307 | info_text, 308 | objects_text, 309 | refs_text, 310 | ) 311 | self.toFadeOut.add( 312 | dot_git_text_arrow, 313 | head_text_arrow, 314 | config_text_arrow, 315 | description_text_arrow, 316 | hooks_text_arrow, 317 | info_text_arrow, 318 | objects_text_arrow, 319 | refs_text_arrow, 320 | ) 321 | self.toFadeOut.add(dot_git_desc, head_desc) 322 | -------------------------------------------------------------------------------- /src/git_sim/log.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from git_sim.git_sim_base_command import GitSimBaseCommand 4 | from git_sim.settings import settings 5 | import numpy 6 | import manim as m 7 | 8 | 9 | class Log(GitSimBaseCommand): 10 | def __init__(self, ctx: typer.Context, n: int, all: bool): 11 | super().__init__() 12 | 13 | n_command = ctx.parent.params.get("n") 14 | self.n_subcommand = n 15 | if self.n_subcommand: 16 | n = self.n_subcommand 17 | else: 18 | n = n_command 19 | self.n = n 20 | self.n_orig = self.n 21 | 22 | all_command = ctx.parent.params.get("all") 23 | self.all_subcommand = all 24 | if self.all_subcommand: 25 | all = self.all_subcommand 26 | else: 27 | all = all_command 28 | self.all = all 29 | 30 | try: 31 | self.selected_branches.append(self.repo.active_branch.name) 32 | except TypeError: 33 | pass 34 | 35 | self.cmd += f"{type(self).__name__.lower()}{' --all' if self.all_subcommand else ''}{' -n ' + str(self.n) if self.n_subcommand else ''}" 36 | 37 | def construct(self): 38 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 39 | print(f"{settings.INFO_STRING} {self.cmd}") 40 | self.show_intro() 41 | self.parse_commits() 42 | self.parse_all() 43 | self.recenter_frame() 44 | self.scale_frame() 45 | self.color_by() 46 | self.show_command_as_title() 47 | self.fadeout() 48 | self.show_outro() 49 | -------------------------------------------------------------------------------- /src/git_sim/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/src/git_sim/logo.png -------------------------------------------------------------------------------- /src/git_sim/merge.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | import git 5 | import manim as m 6 | import numpy 7 | import tempfile 8 | import shutil 9 | import stat 10 | 11 | from git_sim.git_sim_base_command import GitSimBaseCommand 12 | from git_sim.settings import settings 13 | 14 | 15 | class Merge(GitSimBaseCommand): 16 | def __init__(self, branch: str, no_ff: bool, message: str): 17 | super().__init__() 18 | self.branch = branch 19 | self.no_ff = no_ff 20 | self.message = message 21 | 22 | try: 23 | git.repo.fun.rev_parse(self.repo, self.branch) 24 | except git.exc.BadName: 25 | print( 26 | "git-sim error: '" 27 | + self.branch 28 | + "' is not a valid Git ref or identifier." 29 | ) 30 | sys.exit(1) 31 | 32 | self.ff = False 33 | if self.branch in [branch.name for branch in self.repo.heads]: 34 | self.selected_branches.append(self.branch) 35 | 36 | try: 37 | self.selected_branches.append(self.repo.active_branch.name) 38 | except TypeError: 39 | pass 40 | 41 | self.cmd += f"{type(self).__name__.lower()} {self.branch} {'--no-ff' if self.no_ff else ''}" 42 | 43 | def construct(self): 44 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 45 | print(f"{settings.INFO_STRING} {self.cmd}") 46 | 47 | if self.repo.active_branch.name in self.repo.git.branch( 48 | "--contains", self.branch 49 | ): 50 | print( 51 | "git-sim error: Branch '" 52 | + self.branch 53 | + "' is already included in the history of active branch '" 54 | + self.repo.active_branch.name 55 | + "'." 56 | ) 57 | sys.exit(1) 58 | 59 | self.show_intro() 60 | head_commit = self.get_commit() 61 | branch_commit = self.get_commit(self.branch) 62 | 63 | if self.branch not in self.get_remote_tracking_branches(): 64 | if self.branch in self.repo.git.branch("--contains", head_commit.hexsha): 65 | self.ff = True 66 | else: 67 | if self.branch in self.repo.git.branch( 68 | "-r", "--contains", head_commit.hexsha 69 | ): 70 | self.ff = True 71 | 72 | if self.ff: 73 | self.parse_commits(branch_commit) 74 | self.parse_all() 75 | reset_head_to = branch_commit.hexsha 76 | shift = numpy.array([0.0, 0.6, 0.0]) 77 | 78 | if self.no_ff: 79 | self.center_frame_on_commit(branch_commit) 80 | commitId = self.setup_and_draw_parent(branch_commit, self.message) 81 | 82 | # If pre-merge HEAD is on screen, drawn an arrow to it as 2nd parent 83 | if head_commit.hexsha in self.drawnCommits: 84 | start = self.drawnCommits["abcdef"].get_center() 85 | end = self.drawnCommits[head_commit.hexsha].get_center() 86 | arrow = m.CurvedArrow( 87 | start, 88 | end, 89 | color=self.fontColor, 90 | stroke_width=self.arrow_stroke_width, 91 | tip_shape=self.arrow_tip_shape, 92 | ) 93 | self.draw_arrow(True, arrow) 94 | 95 | reset_head_to = "abcdef" 96 | shift = numpy.array([0.0, 0.0, 0.0]) 97 | 98 | self.recenter_frame() 99 | self.scale_frame() 100 | if "HEAD" in self.drawnRefs and self.no_ff: 101 | self.reset_head_branch(reset_head_to, shift=shift) 102 | elif "HEAD" in self.drawnRefs: 103 | self.reset_head_branch_to_ref(self.topref, shift=shift) 104 | else: 105 | self.draw_ref(branch_commit, commitId if self.no_ff else self.topref) 106 | self.draw_ref( 107 | branch_commit, 108 | self.drawnRefs["HEAD"], 109 | text=self.repo.active_branch.name, 110 | color=m.GREEN, 111 | ) 112 | if self.no_ff: 113 | self.color_by(offset=2) 114 | else: 115 | self.color_by() 116 | 117 | else: 118 | merge_result, new_dir = self.check_merge_conflict( 119 | self.repo.active_branch.name, self.branch 120 | ) 121 | if merge_result: 122 | self.hide_first_tag = True 123 | self.parse_commits(head_commit) 124 | self.recenter_frame() 125 | self.scale_frame() 126 | 127 | # Show the conflicted files names in the table/zones 128 | self.vsplit_frame() 129 | self.setup_and_draw_zones( 130 | first_column_name="----", 131 | second_column_name="Conflicted files", 132 | third_column_name="----", 133 | ) 134 | self.color_by() 135 | else: 136 | self.parse_commits(head_commit) 137 | self.parse_commits(branch_commit, shift=4 * m.DOWN) 138 | self.parse_all() 139 | self.center_frame_on_commit(head_commit) 140 | self.setup_and_draw_parent( 141 | head_commit, 142 | self.message, 143 | shift=2 * m.DOWN, 144 | draw_arrow=False, 145 | color=m.GRAY, 146 | ) 147 | self.draw_arrow_between_commits("abcdef", branch_commit.hexsha) 148 | self.draw_arrow_between_commits("abcdef", head_commit.hexsha) 149 | self.recenter_frame() 150 | self.scale_frame() 151 | self.reset_head_branch("abcdef") 152 | self.color_by(offset=2) 153 | 154 | self.show_command_as_title() 155 | self.fadeout() 156 | self.show_outro() 157 | 158 | # Unlink the program from the filesystem 159 | self.repo.git.clear_cache() 160 | 161 | # Delete the local clone 162 | try: 163 | shutil.rmtree(new_dir, onerror=self.del_rw) 164 | except (FileNotFoundError, UnboundLocalError): 165 | pass 166 | 167 | def check_merge_conflict(self, branch1, branch2): 168 | git_root = self.repo.git.rev_parse("--show-toplevel") 169 | repo_name = os.path.basename(self.repo.working_dir) 170 | new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) 171 | 172 | orig_repo = self.repo 173 | orig_remotes = self.repo.remotes 174 | self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) 175 | self.repo.git.checkout(branch2) 176 | self.repo.git.checkout(branch1) 177 | 178 | try: 179 | self.repo.git.merge(branch2) 180 | except git.GitCommandError as e: 181 | if "CONFLICT" in e.stdout: 182 | self.conflicted_files = [] 183 | self.n = 5 184 | for entry in self.repo.index.entries: 185 | if len(entry) == 2 and entry[1] > 0: 186 | self.conflicted_files.append(entry[0]) 187 | return 1, new_dir 188 | self.repo = orig_repo 189 | return 0, new_dir 190 | 191 | # Override to display conflicted filenames 192 | def populate_zones( 193 | self, 194 | firstColumnFileNames, 195 | secondColumnFileNames, 196 | thirdColumnFileNames, 197 | firstColumnArrowMap={}, 198 | secondColumnArrowMap={}, 199 | thirdColumnArrowMap={}, 200 | ): 201 | for filename in self.conflicted_files: 202 | secondColumnFileNames.add(filename) 203 | -------------------------------------------------------------------------------- /src/git_sim/mv.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | import manim as m 4 | 5 | from typing import List 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Mv(GitSimBaseCommand): 12 | def __init__(self, file: str, new_file: str): 13 | super().__init__() 14 | self.hide_first_tag = True 15 | self.allow_no_commits = True 16 | self.file = file 17 | self.new_file = new_file 18 | settings.hide_merged_branches = True 19 | self.n = self.n_default 20 | 21 | try: 22 | self.selected_branches.append(self.repo.active_branch.name) 23 | except TypeError: 24 | pass 25 | 26 | try: 27 | self.repo.git.ls_files("--error-unmatch", self.file) 28 | except: 29 | print(f"git-sim error: No tracked file with name: '{file}'") 30 | sys.exit() 31 | 32 | self.cmd += f"{type(self).__name__.lower()} {self.file} {self.new_file}" 33 | 34 | def construct(self): 35 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 36 | print(f"{settings.INFO_STRING} {self.cmd}") 37 | 38 | self.show_intro() 39 | self.parse_commits() 40 | self.recenter_frame() 41 | self.scale_frame() 42 | self.vsplit_frame() 43 | self.setup_and_draw_zones( 44 | first_column_name="Working directory", 45 | second_column_name="Staging area", 46 | third_column_name="Renamed files", 47 | ) 48 | self.rename_moved_file() 49 | self.show_command_as_title() 50 | self.fadeout() 51 | self.show_outro() 52 | 53 | def populate_zones( 54 | self, 55 | firstColumnFileNames, 56 | secondColumnFileNames, 57 | thirdColumnFileNames, 58 | firstColumnArrowMap={}, 59 | secondColumnArrowMap={}, 60 | thirdColumnArrowMap={}, 61 | ): 62 | if self.file in [x.a_path for x in self.repo.index.diff("HEAD")]: 63 | secondColumnFileNames.add(self.file) 64 | secondColumnArrowMap[self.file] = m.Arrow( 65 | stroke_width=3, color=self.fontColor 66 | ) 67 | else: 68 | firstColumnFileNames.add(self.file) 69 | firstColumnArrowMap[self.file] = m.Arrow( 70 | stroke_width=3, color=self.fontColor 71 | ) 72 | 73 | thirdColumnFileNames.add(self.file) 74 | 75 | def rename_moved_file(self): 76 | for file in self.thirdColumnFiles: 77 | new_file = m.Text( 78 | self.trim_path(self.new_file), 79 | font=self.font, 80 | font_size=24, 81 | color=self.fontColor, 82 | ) 83 | new_file.move_to(file.get_center()) 84 | if settings.animate: 85 | self.play(m.FadeOut(file), run_time=1 / settings.speed) 86 | self.toFadeOut.remove(file) 87 | self.play(m.AddTextLetterByLetter(new_file)) 88 | self.toFadeOut.add(new_file) 89 | else: 90 | self.remove(file) 91 | self.add(new_file) 92 | -------------------------------------------------------------------------------- /src/git_sim/pull.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import Namespace 4 | 5 | import git 6 | import manim as m 7 | import numpy 8 | import tempfile 9 | import shutil 10 | import stat 11 | import re 12 | 13 | from git_sim.git_sim_base_command import GitSimBaseCommand 14 | from git_sim.settings import settings 15 | 16 | 17 | class Pull(GitSimBaseCommand): 18 | def __init__(self, remote: str = None, branch: str = None): 19 | super().__init__() 20 | self.remote = remote 21 | self.branch = branch 22 | settings.max_branches_per_commit = 2 23 | 24 | if self.remote and self.remote not in self.repo.remotes: 25 | print("git-sim error: no remote with name '" + self.remote + "'") 26 | sys.exit(1) 27 | 28 | self.cmd += f"{type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" 29 | 30 | def construct(self): 31 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 32 | print(f"{settings.INFO_STRING} {self.cmd}") 33 | 34 | self.show_intro() 35 | 36 | # Configure paths to make local clone to run networked commands in 37 | git_root = self.repo.git.rev_parse("--show-toplevel") 38 | repo_name = os.path.basename(self.repo.working_dir) 39 | new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) 40 | 41 | # Save remotes and create the local clone 42 | orig_remotes = self.repo.remotes 43 | self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) 44 | 45 | # Reset the remotes in the local clone to the original remotes 46 | for r1 in orig_remotes: 47 | for r2 in self.repo.remotes: 48 | if r1.name == r2.name: 49 | r2.set_url(r1.url) 50 | 51 | # Pull the remote into the local clone 52 | try: 53 | self.repo.git.pull(self.remote, self.branch) 54 | head_commit = self.get_commit() 55 | self.parse_commits(head_commit) 56 | self.recenter_frame() 57 | self.scale_frame() 58 | 59 | # But if we get merge conflicts... 60 | except git.GitCommandError as e: 61 | if "CONFLICT" in e.stdout: 62 | # Restrict to default number of commits since we'll show the table/zones 63 | self.n = self.n_default 64 | settings.hide_merged_branches = True 65 | 66 | # Get list of conflicted filenames 67 | self.conflicted_files = re.findall(r"Merge conflict in (.+)", e.stdout) 68 | 69 | head_commit = self.get_commit() 70 | self.parse_commits(head_commit) 71 | self.recenter_frame() 72 | self.scale_frame() 73 | 74 | # Show the conflicted files names in the table/zones 75 | self.vsplit_frame() 76 | self.setup_and_draw_zones( 77 | first_column_name="----", 78 | second_column_name="Conflicted files", 79 | third_column_name="----", 80 | ) 81 | else: 82 | print( 83 | f"git-sim error: git pull failed for unhandled reason: {e.stdout}" 84 | ) 85 | self.repo.git.clear_cache() 86 | shutil.rmtree(new_dir, onerror=self.del_rw) 87 | sys.exit(1) 88 | 89 | self.color_by() 90 | self.show_command_as_title() 91 | self.fadeout() 92 | self.show_outro() 93 | 94 | # Unlink the program from the filesystem 95 | self.repo.git.clear_cache() 96 | 97 | # Delete the local clone 98 | shutil.rmtree(new_dir, onerror=self.del_rw) 99 | 100 | # Override to display conflicted filenames 101 | def populate_zones( 102 | self, 103 | firstColumnFileNames, 104 | secondColumnFileNames, 105 | thirdColumnFileNames, 106 | firstColumnArrowMap={}, 107 | secondColumnArrowMap={}, 108 | thirdColumnArrowMap={}, 109 | ): 110 | for filename in self.conflicted_files: 111 | secondColumnFileNames.add(filename) 112 | -------------------------------------------------------------------------------- /src/git_sim/push.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from argparse import Namespace 4 | 5 | import git 6 | import manim as m 7 | import numpy 8 | import tempfile 9 | import shutil 10 | import stat 11 | import re 12 | 13 | from git_sim.git_sim_base_command import GitSimBaseCommand 14 | from git_sim.settings import settings 15 | from git_sim.enums import ColorByOptions 16 | 17 | 18 | class Push(GitSimBaseCommand): 19 | def __init__( 20 | self, remote: str = None, branch: str = None, set_upstream: bool = False 21 | ): 22 | super().__init__() 23 | self.remote = remote 24 | self.branch = branch 25 | self.set_upstream = set_upstream 26 | settings.max_branches_per_commit = 2 27 | 28 | if self.remote and self.remote not in self.repo.remotes: 29 | print("git-sim error: no remote with name '" + self.remote + "'") 30 | sys.exit(1) 31 | 32 | 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 ''}" 33 | 34 | def construct(self): 35 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 36 | print(f"{settings.INFO_STRING} {self.cmd}") 37 | 38 | self.show_intro() 39 | 40 | # Configure paths to make local clone to run networked commands in 41 | git_root = self.repo.git.rev_parse("--show-toplevel") 42 | repo_name = os.path.basename(self.repo.working_dir) 43 | new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) 44 | new_dir2 = os.path.join(tempfile.gettempdir(), "git_sim", repo_name + "2") 45 | 46 | # Save remotes 47 | orig_remotes = self.repo.remotes 48 | 49 | # Create local clone of local repo 50 | self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) 51 | if self.remote: 52 | for r in orig_remotes: 53 | if self.remote == r.name: 54 | remote_url = r.url 55 | break 56 | else: 57 | remote_url = orig_remotes[0].url 58 | 59 | # Create local clone of remote repo to simulate push to so we don't touch the real remote 60 | self.remote_repo = git.Repo.clone_from( 61 | remote_url, new_dir2, no_hardlinks=True, bare=True 62 | ) 63 | 64 | # Reset local clone remote to the local clone of remote repo 65 | if self.remote: 66 | for r in self.repo.remotes: 67 | if self.remote == r.name: 68 | r.set_url(new_dir2) 69 | else: 70 | self.repo.remotes[0].set_url(new_dir2) 71 | 72 | # Push the local clone into the local clone of the remote repo 73 | push_result = 0 74 | self.orig_repo = None 75 | try: 76 | self.repo.git.push(self.remote, self.branch) 77 | # If push fails... 78 | except git.GitCommandError as e: 79 | if "rejected" in e.stderr and ("fetch first" in e.stderr): 80 | push_result = 1 81 | self.orig_repo = self.repo 82 | self.repo = self.remote_repo 83 | settings.color_by = ColorByOptions.NOTLOCAL1 84 | elif "rejected" in e.stderr and ("non-fast-forward" in e.stderr): 85 | push_result = 2 86 | self.orig_repo = self.repo 87 | self.repo = self.remote_repo 88 | settings.color_by = ColorByOptions.NOTLOCAL2 89 | else: 90 | print(f"git-sim error: git push failed: {e.stderr}") 91 | return 92 | 93 | head_commit = self.get_commit() 94 | if push_result > 0: 95 | self.parse_commits( 96 | head_commit, 97 | make_branches_remote=( 98 | self.remote if self.remote else self.repo.remotes[0].name 99 | ), 100 | ) 101 | else: 102 | self.parse_commits(head_commit) 103 | 104 | self.recenter_frame() 105 | self.scale_frame() 106 | self.failed_push(push_result) 107 | self.color_by() 108 | self.show_command_as_title() 109 | self.fadeout() 110 | self.show_outro() 111 | 112 | # Unlink the program from the filesystem 113 | self.repo.git.clear_cache() 114 | if self.orig_repo: 115 | self.orig_repo.git.clear_cache() 116 | 117 | # Delete the local clones 118 | shutil.rmtree(new_dir, onerror=self.del_rw) 119 | shutil.rmtree(new_dir2, onerror=self.del_rw) 120 | 121 | def failed_push(self, push_result): 122 | texts = [] 123 | if push_result == 1: 124 | text1 = m.Text( 125 | f"'git push' failed since the remote repo has commits that don't exist locally.", 126 | font=self.font, 127 | font_size=20, 128 | color=self.fontColor, 129 | weight=m.BOLD, 130 | ) 131 | text1.move_to([self.camera.frame.get_center()[0], 5, 0]) 132 | 133 | text2 = m.Text( 134 | f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", 135 | font=self.font, 136 | font_size=20, 137 | color=self.fontColor, 138 | weight=m.BOLD, 139 | ) 140 | text2.move_to(text1.get_center()).shift(m.DOWN / 2) 141 | 142 | text3 = m.Text( 143 | f"Gold commits exist in remote repo, but not locally (need to be pulled).", 144 | font=self.font, 145 | font_size=20, 146 | color=m.GOLD, 147 | weight=m.BOLD, 148 | ) 149 | text3.move_to(text2.get_center()).shift(m.DOWN / 2) 150 | 151 | text4 = m.Text( 152 | f"Red commits exist in both local and remote repos.", 153 | font=self.font, 154 | font_size=20, 155 | color=m.RED, 156 | weight=m.BOLD, 157 | ) 158 | text4.move_to(text3.get_center()).shift(m.DOWN / 2) 159 | texts = [text1, text2, text3, text4] 160 | 161 | elif push_result == 2: 162 | text1 = m.Text( 163 | f"'git push' failed since the tip of your current branch is behind the remote.", 164 | font=self.font, 165 | font_size=20, 166 | color=self.fontColor, 167 | weight=m.BOLD, 168 | ) 169 | text1.move_to([self.camera.frame.get_center()[0], 5, 0]) 170 | 171 | text2 = m.Text( 172 | f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", 173 | font=self.font, 174 | font_size=20, 175 | color=self.fontColor, 176 | weight=m.BOLD, 177 | ) 178 | text2.move_to(text1.get_center()).shift(m.DOWN / 2) 179 | 180 | text3 = m.Text( 181 | f"Gold commits are ahead of your current branch tip (need to be pulled).", 182 | font=self.font, 183 | font_size=20, 184 | color=m.GOLD, 185 | weight=m.BOLD, 186 | ) 187 | text3.move_to(text2.get_center()).shift(m.DOWN / 2) 188 | 189 | text4 = m.Text( 190 | f"Red commits are up to date in both local and remote branches.", 191 | font=self.font, 192 | font_size=20, 193 | color=m.RED, 194 | weight=m.BOLD, 195 | ) 196 | text4.move_to(text3.get_center()).shift(m.DOWN / 2) 197 | texts = [text1, text2, text3, text4] 198 | 199 | self.toFadeOut.add(*texts) 200 | self.recenter_frame() 201 | self.scale_frame() 202 | if settings.animate: 203 | self.play(*[m.AddTextLetterByLetter(t) for t in texts]) 204 | else: 205 | self.add(*texts) 206 | -------------------------------------------------------------------------------- /src/git_sim/rebase.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import git 4 | import manim as m 5 | import numpy 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Rebase(GitSimBaseCommand): 12 | def __init__(self, branch: str): 13 | super().__init__() 14 | self.branch = branch 15 | 16 | try: 17 | git.repo.fun.rev_parse(self.repo, self.branch) 18 | except git.exc.BadName: 19 | print( 20 | "git-sim error: '" 21 | + self.branch 22 | + "' is not a valid Git ref or identifier." 23 | ) 24 | sys.exit(1) 25 | 26 | if self.branch in [branch.name for branch in self.repo.heads]: 27 | self.selected_branches.append(self.branch) 28 | 29 | try: 30 | self.selected_branches.append(self.repo.active_branch.name) 31 | except TypeError: 32 | pass 33 | 34 | self.cmd += f"{type(self).__name__.lower()} {self.branch}" 35 | 36 | def construct(self): 37 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 38 | print(f"{settings.INFO_STRING} {self.cmd}") 39 | 40 | if self.branch in self.repo.git.branch( 41 | "--contains", self.repo.active_branch.name 42 | ): 43 | print( 44 | "git-sim error: Branch '" 45 | + self.repo.active_branch.name 46 | + "' is already included in the history of active branch '" 47 | + self.branch 48 | + "'." 49 | ) 50 | sys.exit(1) 51 | 52 | if self.repo.active_branch.name in self.repo.git.branch( 53 | "--contains", self.branch 54 | ): 55 | print( 56 | "git-sim error: Branch '" 57 | + self.branch 58 | + "' is already based on active branch '" 59 | + self.repo.active_branch.name 60 | + "'." 61 | ) 62 | sys.exit(1) 63 | 64 | self.show_intro() 65 | branch_commit = self.get_commit(self.branch) 66 | self.parse_commits(branch_commit) 67 | head_commit = self.get_commit() 68 | 69 | reached_base = False 70 | for commit in self.get_default_commits(): 71 | if commit != "dark" and self.branch in self.repo.git.branch( 72 | "--contains", commit 73 | ): 74 | reached_base = True 75 | 76 | self.parse_commits(head_commit, shift=4 * m.DOWN) 77 | self.parse_all() 78 | self.center_frame_on_commit(branch_commit) 79 | 80 | to_rebase = [] 81 | i = 0 82 | current = head_commit 83 | while self.branch not in self.repo.git.branch("--contains", current): 84 | to_rebase.append(current) 85 | i += 1 86 | if i >= self.n: 87 | break 88 | current = self.get_default_commits()[i] 89 | 90 | parent = branch_commit.hexsha 91 | 92 | for j, tr in enumerate(reversed(to_rebase)): 93 | if not reached_base and j == 0: 94 | message = "..." 95 | else: 96 | message = tr.message 97 | parent = self.setup_and_draw_parent(parent, message) 98 | self.draw_arrow_between_commits(tr.hexsha, parent) 99 | 100 | self.recenter_frame() 101 | self.scale_frame() 102 | self.reset_head_branch(parent) 103 | self.color_by(offset=2 * len(to_rebase)) 104 | self.show_command_as_title() 105 | self.fadeout() 106 | self.show_outro() 107 | 108 | def setup_and_draw_parent( 109 | self, 110 | child, 111 | commitMessage="New commit", 112 | shift=numpy.array([0.0, 0.0, 0.0]), 113 | draw_arrow=True, 114 | ): 115 | circle = m.Circle( 116 | stroke_color=m.RED, 117 | stroke_width=self.commit_stroke_width, 118 | fill_color=m.RED, 119 | fill_opacity=0.25, 120 | ) 121 | circle.height = 1 122 | circle.next_to( 123 | self.drawnCommits[child], 124 | m.LEFT if settings.reverse else m.RIGHT, 125 | buff=1.5, 126 | ) 127 | circle.shift(shift) 128 | 129 | start = circle.get_center() 130 | end = self.drawnCommits[child].get_center() 131 | arrow = m.Arrow( 132 | start, 133 | end, 134 | color=self.fontColor, 135 | stroke_width=self.arrow_stroke_width, 136 | tip_shape=self.arrow_tip_shape, 137 | max_stroke_width_to_length_ratio=1000, 138 | ) 139 | length = numpy.linalg.norm(start - end) - (1.5 if start[1] == end[1] else 3) 140 | arrow.set_length(length) 141 | 142 | sha = "".join( 143 | chr(ord(letter) + 1) 144 | if ( 145 | (chr(ord(letter) + 1).isalpha() and letter < "f") 146 | or chr(ord(letter) + 1).isdigit() 147 | ) 148 | else letter 149 | for letter in child[:6] 150 | ) 151 | commitId = m.Text( 152 | sha if commitMessage != "..." else "...", 153 | font=self.font, 154 | font_size=20, 155 | color=self.fontColor, 156 | ).next_to(circle, m.UP) 157 | self.toFadeOut.add(commitId) 158 | 159 | commitMessage = commitMessage[:40].replace("\n", " ") 160 | message = m.Text( 161 | "\n".join( 162 | commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) 163 | )[:100], 164 | font=self.font, 165 | font_size=14, 166 | color=self.fontColor, 167 | ).next_to(circle, m.DOWN) 168 | self.toFadeOut.add(message) 169 | 170 | if settings.animate: 171 | self.play( 172 | self.camera.frame.animate.move_to(circle.get_center()), 173 | m.Create(circle), 174 | m.AddTextLetterByLetter(commitId), 175 | m.AddTextLetterByLetter(message), 176 | run_time=1 / settings.speed, 177 | ) 178 | else: 179 | self.camera.frame.move_to(circle.get_center()) 180 | self.add(circle, commitId, message) 181 | 182 | self.drawnCommits[sha] = circle 183 | self.toFadeOut.add(circle) 184 | 185 | if draw_arrow: 186 | if settings.animate: 187 | self.play(m.Create(arrow), run_time=1 / settings.speed) 188 | else: 189 | self.add(arrow) 190 | self.toFadeOut.add(arrow) 191 | 192 | return sha 193 | -------------------------------------------------------------------------------- /src/git_sim/remote.py: -------------------------------------------------------------------------------- 1 | import os 2 | import git 3 | import sys 4 | 5 | import manim as m 6 | 7 | from git.repo import Repo 8 | 9 | from git_sim.settings import settings 10 | from git_sim.enums import RemoteSubCommand 11 | from git_sim.git_sim_base_command import GitSimBaseCommand 12 | 13 | 14 | class Remote(GitSimBaseCommand): 15 | def __init__(self, command: RemoteSubCommand, remote: str, url_or_path: str): 16 | super().__init__() 17 | self.command = command 18 | self.remote = remote 19 | self.url_or_path = url_or_path 20 | 21 | self.config = self.repo.config_reader() 22 | self.time_per_char = 0.05 23 | self.down_shift = m.DOWN * 0.5 24 | 25 | self.cmd += f"{type(self).__name__.lower()}" 26 | if self.command in (RemoteSubCommand.ADD, RemoteSubCommand.RENAME, RemoteSubCommand.SET_URL): 27 | self.cmd += f" {self.command.value} {self.remote} {self.url_or_path}" 28 | elif self.command in (RemoteSubCommand.REMOVE, RemoteSubCommand.GET_URL): 29 | self.cmd += f" {self.command.value} {self.remote}" 30 | 31 | def construct(self): 32 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 33 | print(f"{settings.INFO_STRING} {self.cmd}") 34 | 35 | self.show_intro() 36 | self.add_details() 37 | self.recenter_frame() 38 | self.scale_frame() 39 | self.fadeout() 40 | self.show_outro() 41 | 42 | def add_details(self): 43 | self.camera.frame.scale_to_fit_width(18 * 1.1) 44 | self.project_root = m.Rectangle( 45 | height=9.0, 46 | width=18.0, 47 | color=self.fontColor, 48 | ).move_to((0, 1000, 0)) 49 | self.camera.frame.scale_to_fit_width(18 * 1.1) 50 | self.camera.frame.move_to(self.project_root.get_center()) 51 | 52 | cmd_text = m.Text( 53 | self.trim_cmd(self.cmd, 50), 54 | font=self.font, 55 | font_size=36, 56 | color=self.fontColor, 57 | ) 58 | cmd_text.align_to(self.project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) 59 | 60 | project_root_text = m.Text( 61 | os.path.basename(os.getcwd()) + "/", 62 | font=self.font, 63 | font_size=20, 64 | color=self.fontColor, 65 | ) 66 | project_root_text.align_to(self.project_root, m.LEFT).align_to( 67 | self.project_root, m.UP 68 | ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) 69 | 70 | dot_git_text = m.Text( 71 | ".git/", 72 | font=self.font, 73 | font_size=20, 74 | color=self.fontColor, 75 | ) 76 | dot_git_text.align_to(project_root_text, m.UP).shift(self.down_shift).align_to( 77 | project_root_text, m.LEFT 78 | ).shift(m.RIGHT * 0.5) 79 | 80 | self.config_text = m.Text( 81 | "config", 82 | font=self.font, 83 | font_size=20, 84 | color=self.fontColor, 85 | ) 86 | self.config_text.align_to(dot_git_text, m.UP).shift(self.down_shift).align_to( 87 | dot_git_text, m.LEFT 88 | ).shift(m.RIGHT * 0.5) 89 | self.last_element = self.config_text 90 | 91 | if settings.animate: 92 | if settings.show_command_as_title: 93 | self.play( 94 | m.AddTextLetterByLetter(cmd_text, time_per_char=self.time_per_char) 95 | ) 96 | self.play(m.Create(self.project_root, time_per_char=self.time_per_char)) 97 | self.play( 98 | m.AddTextLetterByLetter( 99 | project_root_text, time_per_char=self.time_per_char 100 | ) 101 | ) 102 | self.play( 103 | m.AddTextLetterByLetter(dot_git_text, time_per_char=self.time_per_char) 104 | ) 105 | self.play( 106 | m.AddTextLetterByLetter( 107 | self.config_text, time_per_char=self.time_per_char 108 | ) 109 | ) 110 | else: 111 | if settings.show_command_as_title: 112 | self.add(cmd_text) 113 | self.add(self.project_root) 114 | self.add(project_root_text) 115 | self.add(dot_git_text) 116 | self.add(self.config_text) 117 | 118 | if not self.command: 119 | self.render_remote_data() 120 | elif self.command == RemoteSubCommand.ADD: 121 | if not self.remote: 122 | print("git-sim error: no new remote name specified") 123 | sys.exit(1) 124 | elif not self.url_or_path: 125 | print("git-sim error: no new remote url or path specified") 126 | sys.exit(1) 127 | elif any( 128 | self.remote in r 129 | for r in [s for s in self.config.sections() if "remote" in s] 130 | ): 131 | print(f"git-sim error: remote '{self.remote}' already exists") 132 | sys.exit(1) 133 | self.render_remote_data() 134 | section_text = ( 135 | m.Text( 136 | f'[remote "{self.remote}"]', 137 | font=self.font, 138 | color=self.fontColor, 139 | font_size=20, 140 | weight=m.BOLD, 141 | ) 142 | .align_to(self.last_element, m.UP) 143 | .shift(self.down_shift) 144 | .align_to(self.config_text, m.LEFT) 145 | .shift(m.RIGHT * 0.5) 146 | ) 147 | url_text = ( 148 | m.Text( 149 | f"url = {self.url_or_path}", 150 | font=self.font, 151 | color=self.fontColor, 152 | font_size=20, 153 | weight=m.BOLD, 154 | ) 155 | .align_to(section_text, m.UP) 156 | .shift(self.down_shift) 157 | .align_to(section_text, m.LEFT) 158 | .shift(m.RIGHT * 0.5) 159 | ) 160 | fetch_text = ( 161 | m.Text( 162 | f"fetch = +refs/heads/*:refs/remotes/{self.remote}/*", 163 | font=self.font, 164 | color=self.fontColor, 165 | font_size=20, 166 | weight=m.BOLD, 167 | ) 168 | .align_to(url_text, m.UP) 169 | .shift(self.down_shift) 170 | .align_to(section_text, m.LEFT) 171 | .shift(m.RIGHT * 0.5) 172 | ) 173 | self.toFadeOut.add(section_text) 174 | self.toFadeOut.add(url_text) 175 | self.toFadeOut.add(fetch_text) 176 | if settings.animate: 177 | self.play( 178 | m.AddTextLetterByLetter( 179 | section_text, time_per_char=self.time_per_char 180 | ) 181 | ) 182 | self.play( 183 | m.AddTextLetterByLetter(url_text, time_per_char=self.time_per_char) 184 | ) 185 | self.play( 186 | m.AddTextLetterByLetter( 187 | fetch_text, time_per_char=self.time_per_char 188 | ) 189 | ) 190 | else: 191 | self.add(section_text) 192 | self.add(url_text) 193 | self.add(fetch_text) 194 | elif self.command in (RemoteSubCommand.RENAME, RemoteSubCommand.SET_URL): 195 | if not self.remote: 196 | print("git-sim error: no new remote name specified") 197 | sys.exit(1) 198 | elif not any( 199 | self.remote in r 200 | for r in [s for s in self.config.sections() if "remote" in s] 201 | ): 202 | print(f"git-sim error: remote '{self.remote}' doesn't exist") 203 | sys.exit(1) 204 | elif not self.url_or_path: 205 | print(f"git-sim error: new remote name not specified") 206 | sys.exit(1) 207 | self.render_remote_data() 208 | elif self.command in (RemoteSubCommand.REMOVE, RemoteSubCommand.GET_URL): 209 | if not self.remote: 210 | print("git-sim error: no new remote name specified") 211 | sys.exit(1) 212 | elif not any( 213 | self.remote in r 214 | for r in [s for s in self.config.sections() if "remote" in s] 215 | ): 216 | print(f"git-sim error: remote '{self.remote}' doesn't exist") 217 | sys.exit(1) 218 | self.render_remote_data() 219 | 220 | if settings.show_command_as_title: 221 | self.toFadeOut.add(cmd_text) 222 | self.toFadeOut.add(self.project_root) 223 | self.toFadeOut.add(project_root_text) 224 | self.toFadeOut.add(dot_git_text) 225 | self.toFadeOut.add(self.config_text) 226 | 227 | def resize_rectangle(self): 228 | if ( 229 | self.last_element.get_bottom()[1] - 3 * self.last_element.height 230 | > self.project_root.get_bottom()[1] 231 | ): 232 | return 233 | new_rect = m.Rectangle( 234 | width=rect.width, 235 | height=rect.height + 2 * self.last_element.height, 236 | color=rect.color, 237 | ) 238 | new_rect.align_to(rect, m.UP) 239 | self.toFadeOut.remove(rect) 240 | self.toFadeOut.add(new_rect) 241 | if settings.animate: 242 | self.recenter_frame() 243 | self.scale_frame() 244 | self.play(m.ReplacementTransform(rect, new_rect)) 245 | else: 246 | self.remove(rect) 247 | self.add(new_rect) 248 | self.project_root = new_rect 249 | 250 | def render_remote_data(self): 251 | for i, section in enumerate(self.config.sections()): 252 | if "remote" in section: 253 | if self.command == RemoteSubCommand.RENAME and self.remote in section: 254 | section_text = ( 255 | m.Text( 256 | f'[remote "{self.url_or_path}"]', 257 | font=self.font, 258 | color=self.fontColor, 259 | font_size=20, 260 | weight=m.BOLD, 261 | ) 262 | .align_to(self.last_element, m.UP) 263 | .shift(self.down_shift) 264 | .align_to(self.config_text, m.LEFT) 265 | .shift(m.RIGHT * 0.5) 266 | ) 267 | elif self.command == RemoteSubCommand.REMOVE and self.remote in section: 268 | section_text = ( 269 | m.MarkupText( 270 | "" 273 | + f"[{section}]" 274 | + "", 275 | font=self.font, 276 | color=self.fontColor, 277 | font_size=20, 278 | weight=m.BOLD, 279 | ) 280 | .align_to(self.last_element, m.UP) 281 | .shift(self.down_shift) 282 | .align_to(self.config_text, m.LEFT) 283 | .shift(m.RIGHT * 0.5) 284 | ) 285 | else: 286 | section_text = ( 287 | m.Text( 288 | f"[{section}]", 289 | font=self.font, 290 | color=self.fontColor, 291 | font_size=20, 292 | ) 293 | .align_to(self.last_element, m.UP) 294 | .shift(self.down_shift) 295 | .align_to(self.config_text, m.LEFT) 296 | .shift(m.RIGHT * 0.5) 297 | ) 298 | self.toFadeOut.add(section_text) 299 | if settings.animate: 300 | self.play( 301 | m.AddTextLetterByLetter( 302 | section_text, time_per_char=self.time_per_char 303 | ) 304 | ) 305 | else: 306 | self.add(section_text) 307 | self.last_element = section_text 308 | self.resize_rectangle() 309 | for j, option in enumerate(self.config.options(section)): 310 | if option != "__name__": 311 | option_value = ( 312 | f"{option} = {self.config.get_value(section, option)}" 313 | ) 314 | if ( 315 | self.command == RemoteSubCommand.REMOVE 316 | and self.remote in section 317 | ): 318 | option_text = ( 319 | m.MarkupText( 320 | "" 323 | + option_value 324 | + "", 325 | font=self.font, 326 | color=self.fontColor, 327 | font_size=20, 328 | weight=m.BOLD, 329 | ) 330 | .align_to(self.last_element, m.UP) 331 | .shift(self.down_shift) 332 | .align_to(section_text, m.LEFT) 333 | .shift(m.RIGHT * 0.5) 334 | ) 335 | else: 336 | weight = m.NORMAL 337 | if ( 338 | self.command == RemoteSubCommand.RENAME 339 | and option == "fetch" 340 | and self.remote in section 341 | ): 342 | option_value = f"fetch = +refs/heads/*:refs/remotes/{self.url_or_path}/*" 343 | weight = m.BOLD 344 | elif ( 345 | self.command == RemoteSubCommand.GET_URL 346 | and option == "url" 347 | and self.remote in section 348 | ): 349 | weight = m.BOLD 350 | elif ( 351 | self.command == RemoteSubCommand.SET_URL 352 | and option == "url" 353 | and self.remote in section 354 | ): 355 | option_value = f"{option} = {self.url_or_path}" 356 | weight = m.BOLD 357 | option_text = ( 358 | m.Text( 359 | option_value, 360 | font=self.font, 361 | color=self.fontColor, 362 | font_size=20, 363 | weight=weight, 364 | ) 365 | .align_to(self.last_element, m.UP) 366 | .shift(self.down_shift) 367 | .align_to(section_text, m.LEFT) 368 | .shift(m.RIGHT * 0.5) 369 | ) 370 | self.toFadeOut.add(option_text) 371 | self.last_element = option_text 372 | if settings.animate: 373 | self.play( 374 | m.AddTextLetterByLetter( 375 | option_text, time_per_char=self.time_per_char 376 | ) 377 | ) 378 | else: 379 | self.add(option_text) 380 | if not ( 381 | i == len(self.config.sections()) - 1 382 | and j == len(self.config.options(section)) - 1 383 | ): 384 | self.resize_rectangle() 385 | -------------------------------------------------------------------------------- /src/git_sim/reset.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | 4 | import git 5 | import manim as m 6 | 7 | from git_sim.enums import ResetMode 8 | from git_sim.git_sim_base_command import GitSimBaseCommand 9 | from git_sim.settings import settings 10 | 11 | 12 | class Reset(GitSimBaseCommand): 13 | def __init__( 14 | self, commit: str, mode: ResetMode, soft: bool, mixed: bool, hard: bool 15 | ): 16 | super().__init__() 17 | self.commit = commit 18 | self.mode = mode 19 | settings.hide_merged_branches = True 20 | 21 | try: 22 | self.resetTo = git.repo.fun.rev_parse(self.repo, self.commit) 23 | except git.exc.BadName: 24 | print( 25 | f"git-sim error: '{self.commit}' is not a valid Git ref or identifier." 26 | ) 27 | sys.exit(1) 28 | 29 | self.commitsSinceResetTo = list(self.repo.iter_commits(self.commit + "...HEAD")) 30 | self.n = self.n_default 31 | 32 | try: 33 | self.selected_branches.append(self.repo.active_branch.name) 34 | except TypeError: 35 | pass 36 | 37 | if hard: 38 | self.mode = ResetMode.HARD 39 | if mixed: 40 | self.mode = ResetMode.MIXED 41 | if soft: 42 | self.mode = ResetMode.SOFT 43 | 44 | self.cmd += f"{type(self).__name__.lower()}{' --' + self.mode.value if self.mode != ResetMode.DEFAULT else ''} {self.commit}" 45 | 46 | def construct(self): 47 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 48 | print(f"{settings.INFO_STRING} {self.cmd}") 49 | 50 | self.show_intro() 51 | self.parse_commits() 52 | self.recenter_frame() 53 | self.scale_frame() 54 | self.reset_head_branch(self.resetTo.hexsha) 55 | self.vsplit_frame() 56 | self.setup_and_draw_zones(first_column_name="Changes deleted from") 57 | self.show_command_as_title() 58 | self.fadeout() 59 | self.show_outro() 60 | 61 | def build_commit_id_and_message(self, commit, i): 62 | hide_refs = False 63 | if commit == "dark": 64 | commitId = m.Text("", font=self.font, font_size=20, color=self.fontColor) 65 | commitMessage = "" 66 | elif i == 3 and self.resetTo.hexsha not in [ 67 | c.hexsha for c in self.get_default_commits() 68 | ]: 69 | commitId = m.Text("...", font=self.font, font_size=20, color=self.fontColor) 70 | commitMessage = "..." 71 | hide_refs = True 72 | elif i == 4 and self.resetTo.hexsha not in [ 73 | c.hexsha for c in self.get_default_commits() 74 | ]: 75 | commitId = m.Text( 76 | self.resetTo.hexsha[:6], 77 | font=self.font, 78 | font_size=20, 79 | color=self.fontColor, 80 | ) 81 | commitMessage = self.resetTo.message.split("\n")[0][:40].replace("\n", " ") 82 | commit = self.resetTo 83 | hide_refs = True 84 | else: 85 | commitId = m.Text( 86 | commit.hexsha[:6], 87 | font=self.font, 88 | font_size=20, 89 | color=self.fontColor, 90 | ) 91 | commitMessage = commit.message.split("\n")[0][:40].replace("\n", " ") 92 | 93 | if ( 94 | commit != "dark" 95 | and commit.hexsha == self.resetTo.hexsha 96 | and commit.hexsha != self.repo.head.commit.hexsha 97 | ): 98 | hide_refs = True 99 | 100 | return commitId, commitMessage, commit, hide_refs 101 | 102 | def populate_zones( 103 | self, 104 | firstColumnFileNames, 105 | secondColumnFileNames, 106 | thirdColumnFileNames, 107 | firstColumnArrowMap={}, 108 | secondColumnArrowMap={}, 109 | thirdColumnArrowMap={}, 110 | ): 111 | for commit in self.commitsSinceResetTo: 112 | if commit.hexsha == self.resetTo.hexsha: 113 | break 114 | for filename in commit.stats.files: 115 | if self.mode == ResetMode.SOFT: 116 | thirdColumnFileNames.add(filename) 117 | elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): 118 | secondColumnFileNames.add(filename) 119 | elif self.mode == ResetMode.HARD: 120 | firstColumnFileNames.add(filename) 121 | 122 | for x in self.repo.index.diff(None): 123 | if "git-sim_media" not in x.a_path: 124 | if self.mode == ResetMode.SOFT: 125 | secondColumnFileNames.add(x.a_path) 126 | elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): 127 | secondColumnFileNames.add(x.a_path) 128 | elif self.mode == ResetMode.HARD: 129 | firstColumnFileNames.add(x.a_path) 130 | 131 | for y in self.repo.index.diff("HEAD"): 132 | if "git-sim_media" not in y.a_path: 133 | if self.mode == ResetMode.SOFT: 134 | thirdColumnFileNames.add(y.a_path) 135 | elif self.mode in (ResetMode.MIXED, ResetMode.DEFAULT): 136 | secondColumnFileNames.add(y.a_path) 137 | elif self.mode == ResetMode.HARD: 138 | firstColumnFileNames.add(y.a_path) 139 | -------------------------------------------------------------------------------- /src/git_sim/restore.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import manim as m 3 | 4 | from typing import List 5 | 6 | from git_sim.git_sim_base_command import GitSimBaseCommand 7 | from git_sim.settings import settings 8 | 9 | 10 | class Restore(GitSimBaseCommand): 11 | def __init__(self, files: List[str], staged: bool): 12 | super().__init__() 13 | self.files = files 14 | self.staged = staged 15 | settings.hide_merged_branches = True 16 | self.n = self.n_default 17 | 18 | try: 19 | self.selected_branches.append(self.repo.active_branch.name) 20 | except TypeError: 21 | pass 22 | 23 | if not self.staged: 24 | for file in self.files: 25 | if file not in [x.a_path for x in self.repo.index.diff(None)]: 26 | print(f"git-sim error: No modified file with name: '{file}'") 27 | sys.exit() 28 | else: 29 | for file in self.files: 30 | if file not in [y.a_path for y in self.repo.index.diff("HEAD")]: 31 | print( 32 | f"git-sim error: No modified or staged file with name: '{file}'" 33 | ) 34 | sys.exit() 35 | 36 | self.cmd += f"{type(self).__name__.lower()}{' --staged' if self.staged else ''} {' '.join(self.files)}" 37 | 38 | def construct(self): 39 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 40 | print(f"{settings.INFO_STRING} {self.cmd}") 41 | 42 | self.show_intro() 43 | self.parse_commits() 44 | self.recenter_frame() 45 | self.scale_frame() 46 | self.vsplit_frame() 47 | self.setup_and_draw_zones(reverse=True) 48 | self.show_command_as_title() 49 | self.fadeout() 50 | self.show_outro() 51 | 52 | def populate_zones( 53 | self, 54 | firstColumnFileNames, 55 | secondColumnFileNames, 56 | thirdColumnFileNames, 57 | firstColumnArrowMap={}, 58 | secondColumnArrowMap={}, 59 | thirdColumnArrowMap={}, 60 | ): 61 | for x in self.repo.index.diff(None): 62 | if "git-sim_media" not in x.a_path: 63 | secondColumnFileNames.add(x.a_path) 64 | for file in self.files: 65 | if file == x.a_path: 66 | thirdColumnFileNames.add(x.a_path) 67 | secondColumnArrowMap[x.a_path] = m.Arrow( 68 | stroke_width=3, color=self.fontColor 69 | ) 70 | 71 | for y in self.repo.index.diff("HEAD"): 72 | if "git-sim_media" not in y.a_path: 73 | firstColumnFileNames.add(y.a_path) 74 | for file in self.files: 75 | if file == y.a_path: 76 | secondColumnFileNames.add(y.a_path) 77 | firstColumnArrowMap[y.a_path] = m.Arrow( 78 | stroke_width=3, color=self.fontColor 79 | ) 80 | -------------------------------------------------------------------------------- /src/git_sim/revert.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import git 4 | import manim as m 5 | import numpy 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Revert(GitSimBaseCommand): 12 | def __init__(self, commit: str): 13 | super().__init__() 14 | self.commit = commit 15 | 16 | try: 17 | self.revert = git.repo.fun.rev_parse(self.repo, self.commit) 18 | except git.exc.BadName: 19 | print( 20 | "git-sim error: '" 21 | + self.commit 22 | + "' is not a valid Git ref or identifier." 23 | ) 24 | sys.exit(1) 25 | 26 | self.n_default = 4 27 | self.n = self.n_default 28 | settings.hide_merged_branches = True 29 | 30 | self.zone_title_offset += 0.1 31 | 32 | try: 33 | self.selected_branches.append(self.repo.active_branch.name) 34 | except TypeError: 35 | pass 36 | 37 | self.cmd += f"{type(self).__name__.lower()} {self.commit}" 38 | 39 | def construct(self): 40 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 41 | print(f"{settings.INFO_STRING} {self.cmd}") 42 | 43 | self.show_intro() 44 | self.parse_commits() 45 | self.center_frame_on_commit(self.get_commit()) 46 | self.setup_and_draw_revert_commit() 47 | self.recenter_frame() 48 | self.scale_frame() 49 | self.reset_head_branch("abcdef") 50 | self.vsplit_frame() 51 | self.setup_and_draw_zones( 52 | first_column_name="----", 53 | second_column_name="Changes reverted from", 54 | third_column_name="----", 55 | ) 56 | self.show_command_as_title() 57 | self.fadeout() 58 | self.show_outro() 59 | 60 | def build_commit_id_and_message(self, commit, i): 61 | hide_refs = False 62 | if commit == "dark": 63 | commitId = m.Text("", font=self.font, font_size=20, color=self.fontColor) 64 | commitMessage = "" 65 | elif i == 2 and self.revert.hexsha not in [ 66 | commit.hexsha for commit in self.get_default_commits() 67 | ]: 68 | commitId = m.Text("...", font=self.font, font_size=20, color=self.fontColor) 69 | commitMessage = "..." 70 | hide_refs = True 71 | elif i == 3 and self.revert.hexsha not in [ 72 | commit.hexsha for commit in self.get_default_commits() 73 | ]: 74 | commitId = m.Text( 75 | self.revert.hexsha[:6], 76 | font=self.font, 77 | font_size=20, 78 | color=self.fontColor, 79 | ) 80 | commitMessage = self.revert.message.split("\n")[0][:40].replace("\n", " ") 81 | hide_refs = True 82 | else: 83 | commitId = m.Text( 84 | commit.hexsha[:6], 85 | font=self.font, 86 | font_size=20, 87 | color=self.fontColor, 88 | ) 89 | commitMessage = commit.message.split("\n")[0][:40].replace("\n", " ") 90 | return commitId, commitMessage, commit, hide_refs 91 | 92 | def setup_and_draw_revert_commit(self): 93 | circle = m.Circle( 94 | stroke_color=m.RED, 95 | stroke_width=self.commit_stroke_width, 96 | fill_color=m.RED, 97 | fill_opacity=0.25, 98 | ) 99 | circle.height = 1 100 | circle.next_to( 101 | self.drawnCommits[self.get_commit().hexsha], 102 | m.LEFT if settings.reverse else m.RIGHT, 103 | buff=1.5, 104 | ) 105 | 106 | start = circle.get_center() 107 | end = self.drawnCommits[self.get_commit().hexsha].get_center() 108 | arrow = m.Arrow( 109 | start, 110 | end, 111 | color=self.fontColor, 112 | stroke_width=self.arrow_stroke_width, 113 | tip_shape=self.arrow_tip_shape, 114 | max_stroke_width_to_length_ratio=1000, 115 | ) 116 | length = numpy.linalg.norm(start - end) - (1.5 if start[1] == end[1] else 3) 117 | arrow.set_length(length) 118 | 119 | commitId = m.Text( 120 | "abcdef", font=self.font, font_size=20, color=self.fontColor 121 | ).next_to(circle, m.UP) 122 | self.toFadeOut.add(commitId) 123 | 124 | commitMessage = "Revert " + self.revert.hexsha[0:6] 125 | commitMessage = commitMessage[:40].replace("\n", " ") 126 | message = m.Text( 127 | "\n".join( 128 | commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) 129 | )[:100], 130 | font=self.font, 131 | font_size=14, 132 | color=self.fontColor, 133 | ).next_to(circle, m.DOWN) 134 | self.toFadeOut.add(message) 135 | 136 | if settings.animate: 137 | self.play( 138 | self.camera.frame.animate.move_to(circle.get_center()), 139 | m.Create(circle), 140 | m.AddTextLetterByLetter(commitId), 141 | m.AddTextLetterByLetter(message), 142 | run_time=1 / settings.speed, 143 | ) 144 | else: 145 | self.camera.frame.move_to(circle.get_center()) 146 | self.add(circle, commitId, message) 147 | 148 | self.drawnCommits["abcdef"] = circle 149 | self.toFadeOut.add(circle) 150 | 151 | if settings.animate: 152 | self.play(m.Create(arrow), run_time=1 / settings.speed) 153 | else: 154 | self.add(arrow) 155 | 156 | self.toFadeOut.add(arrow) 157 | 158 | def populate_zones( 159 | self, 160 | firstColumnFileNames, 161 | secondColumnFileNames, 162 | thirdColumnFileNames, 163 | firstColumnArrowMap={}, 164 | secondColumnArrowMap={}, 165 | thirdColumnArrowMap={}, 166 | ): 167 | for filename in self.revert.stats.files: 168 | secondColumnFileNames.add(filename) 169 | -------------------------------------------------------------------------------- /src/git_sim/rm.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | import manim as m 4 | 5 | from typing import List 6 | 7 | from git_sim.git_sim_base_command import GitSimBaseCommand 8 | from git_sim.settings import settings 9 | 10 | 11 | class Rm(GitSimBaseCommand): 12 | def __init__(self, files: List[str]): 13 | super().__init__() 14 | self.hide_first_tag = True 15 | self.allow_no_commits = True 16 | self.files = files 17 | settings.hide_merged_branches = True 18 | self.n = self.n_default 19 | 20 | try: 21 | self.selected_branches.append(self.repo.active_branch.name) 22 | except TypeError: 23 | pass 24 | 25 | for file in self.files: 26 | try: 27 | self.repo.git.ls_files("--error-unmatch", file) 28 | except: 29 | print(f"git-sim error: No tracked file with name: '{file}'") 30 | sys.exit() 31 | 32 | self.cmd += f"{type(self).__name__.lower()} {' '.join(self.files)}" 33 | 34 | def construct(self): 35 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 36 | print(f"{settings.INFO_STRING} {self.cmd}") 37 | 38 | self.show_intro() 39 | self.parse_commits() 40 | self.recenter_frame() 41 | self.scale_frame() 42 | self.vsplit_frame() 43 | self.setup_and_draw_zones( 44 | first_column_name="Working directory", 45 | second_column_name="Staging area", 46 | third_column_name="Removed files", 47 | ) 48 | self.show_command_as_title() 49 | self.fadeout() 50 | self.show_outro() 51 | 52 | def create_zone_text( 53 | self, 54 | firstColumnFileNames, 55 | secondColumnFileNames, 56 | thirdColumnFileNames, 57 | firstColumnFiles, 58 | secondColumnFiles, 59 | thirdColumnFiles, 60 | firstColumnFilesDict, 61 | secondColumnFilesDict, 62 | thirdColumnFilesDict, 63 | firstColumnTitle, 64 | secondColumnTitle, 65 | thirdColumnTitle, 66 | horizontal2, 67 | ): 68 | for i, f in enumerate(firstColumnFileNames): 69 | text = ( 70 | m.Text( 71 | self.trim_path(f), 72 | font=self.font, 73 | font_size=24, 74 | color=self.fontColor, 75 | ) 76 | .move_to( 77 | (firstColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 78 | ) 79 | .shift(m.DOWN * 0.5 * (i + 1)) 80 | ) 81 | firstColumnFiles.add(text) 82 | firstColumnFilesDict[f] = text 83 | 84 | for j, f in enumerate(secondColumnFileNames): 85 | text = ( 86 | m.Text( 87 | self.trim_path(f), 88 | font=self.font, 89 | font_size=24, 90 | color=self.fontColor, 91 | ) 92 | .move_to( 93 | (secondColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 94 | ) 95 | .shift(m.DOWN * 0.5 * (j + 1)) 96 | ) 97 | secondColumnFiles.add(text) 98 | secondColumnFilesDict[f] = text 99 | 100 | for h, f in enumerate(thirdColumnFileNames): 101 | text = ( 102 | m.MarkupText( 103 | "" 106 | + self.trim_path(f) 107 | + "", 108 | font=self.font, 109 | font_size=24, 110 | color=self.fontColor, 111 | ) 112 | .move_to( 113 | (thirdColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 114 | ) 115 | .shift(m.DOWN * 0.5 * (h + 1)) 116 | ) 117 | thirdColumnFiles.add(text) 118 | thirdColumnFilesDict[f] = text 119 | 120 | def populate_zones( 121 | self, 122 | firstColumnFileNames, 123 | secondColumnFileNames, 124 | thirdColumnFileNames, 125 | firstColumnArrowMap={}, 126 | secondColumnArrowMap={}, 127 | thirdColumnArrowMap={}, 128 | ): 129 | for file in self.files: 130 | if file in [x.a_path for x in self.repo.index.diff("HEAD")]: 131 | secondColumnFileNames.add(file) 132 | secondColumnArrowMap[file] = m.Arrow( 133 | stroke_width=3, color=self.fontColor 134 | ) 135 | else: 136 | firstColumnFileNames.add(file) 137 | firstColumnArrowMap[file] = m.Arrow( 138 | stroke_width=3, color=self.fontColor 139 | ) 140 | 141 | thirdColumnFileNames.add(file) 142 | -------------------------------------------------------------------------------- /src/git_sim/settings.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import List, Union 3 | 4 | from pydantic_settings import BaseSettings 5 | 6 | from git_sim.enums import StyleOptions, ColorByOptions, ImgFormat, VideoFormat 7 | 8 | 9 | class Settings(BaseSettings): 10 | allow_no_commits: bool = False 11 | animate: bool = False 12 | auto_open: bool = True 13 | n_default: int = 5 14 | n: int = 5 15 | files: Union[List[pathlib.Path], None] = None 16 | hide_first_tag: bool = False 17 | img_format: ImgFormat = ImgFormat.JPG 18 | INFO_STRING: str = "Simulating:" 19 | light_mode: bool = False 20 | transparent_bg: bool = False 21 | logo: pathlib.Path = pathlib.Path(__file__).parent.resolve() / "logo.png" 22 | low_quality: bool = False 23 | max_branches_per_commit: int = 1 24 | max_tags_per_commit: int = 1 25 | media_dir: pathlib.Path = pathlib.Path().cwd() 26 | outro_bottom_text: str = "Learn more at initialcommit.com" 27 | outro_top_text: str = "Thanks for using Initial Commit!" 28 | reverse: bool = False 29 | show_intro: bool = False 30 | show_outro: bool = False 31 | speed: float = 1.5 32 | title: str = "Git-Sim, by initialcommit.com" 33 | video_format: VideoFormat = VideoFormat.MP4 34 | stdout: bool = False 35 | output_only_path: bool = False 36 | quiet: bool = False 37 | invert_branches: bool = False 38 | hide_merged_branches: bool = False 39 | all: bool = False 40 | color_by: Union[ColorByOptions, None] = None 41 | highlight_commit_messages: bool = False 42 | style: Union[StyleOptions, None] = StyleOptions.CLEAN 43 | font: str = "Monospace" 44 | font_context: bool = False 45 | show_command_as_title: bool = True 46 | 47 | class Config: 48 | env_prefix = "git_sim_" 49 | 50 | 51 | settings = Settings() 52 | -------------------------------------------------------------------------------- /src/git_sim/stash.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from enum import Enum 4 | import manim as m 5 | 6 | from typing import List 7 | 8 | from git_sim.enums import StashSubCommand 9 | from git_sim.git_sim_base_command import GitSimBaseCommand 10 | from git_sim.settings import settings 11 | 12 | 13 | class Stash(GitSimBaseCommand): 14 | def __init__(self, files: List[str], command: StashSubCommand, stash_index: int): 15 | super().__init__() 16 | self.files = files 17 | self.no_files = True if not self.files else False 18 | self.command = command 19 | settings.hide_merged_branches = True 20 | self.n = self.n_default 21 | 22 | self.stash_index = self.parse_stash_format(stash_index) 23 | if self.stash_index is None: 24 | print("git-sim error: specify stash index as either integer or stash@{i}") 25 | sys.exit() 26 | 27 | try: 28 | self.selected_branches.append(self.repo.active_branch.name) 29 | except TypeError: 30 | pass 31 | 32 | if self.command in [StashSubCommand.PUSH, None]: 33 | for file in self.files: 34 | if file not in [x.a_path for x in self.repo.index.diff(None)] + [ 35 | y.a_path for y in self.repo.index.diff("HEAD") 36 | ]: 37 | print( 38 | f"git-sim error: No modified or staged file with name: '{file}'" 39 | ) 40 | sys.exit() 41 | 42 | if not self.files: 43 | self.files = [x.a_path for x in self.repo.index.diff(None)] + [ 44 | y.a_path for y in self.repo.index.diff("HEAD") 45 | ] 46 | elif self.files: 47 | if ( 48 | not settings.stdout 49 | and not settings.output_only_path 50 | and not settings.quiet 51 | ): 52 | print( 53 | "Files are not required in apply/pop subcommand. Ignoring the file list..." 54 | ) 55 | 56 | self.cmd += f"{type(self).__name__.lower()} {self.command.value if self.command else ''} {' '.join(self.files) if not self.no_files else ''}" 57 | 58 | def construct(self): 59 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 60 | print(f"{settings.INFO_STRING} {self.cmd}") 61 | 62 | self.show_intro() 63 | self.parse_commits() 64 | self.recenter_frame() 65 | self.scale_frame() 66 | self.vsplit_frame() 67 | self.setup_and_draw_zones( 68 | first_column_name="Working directory", 69 | second_column_name="Staging area", 70 | third_column_name="Stashed changes", 71 | ) 72 | self.show_command_as_title() 73 | self.fadeout() 74 | self.show_outro() 75 | 76 | def create_zone_text( 77 | self, 78 | firstColumnFileNames, 79 | secondColumnFileNames, 80 | thirdColumnFileNames, 81 | firstColumnFiles, 82 | secondColumnFiles, 83 | thirdColumnFiles, 84 | firstColumnFilesDict, 85 | secondColumnFilesDict, 86 | thirdColumnFilesDict, 87 | firstColumnTitle, 88 | secondColumnTitle, 89 | thirdColumnTitle, 90 | horizontal2, 91 | ): 92 | for i, f in enumerate(firstColumnFileNames): 93 | text = ( 94 | m.Text( 95 | self.trim_path(f), 96 | font=self.font, 97 | font_size=24, 98 | color=self.fontColor, 99 | ) 100 | .move_to( 101 | (firstColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 102 | ) 103 | .shift(m.DOWN * 0.5 * (i + 1)) 104 | ) 105 | firstColumnFiles.add(text) 106 | firstColumnFilesDict[f] = text 107 | 108 | for j, f in enumerate(secondColumnFileNames): 109 | text = ( 110 | m.Text( 111 | self.trim_path(f), 112 | font=self.font, 113 | font_size=24, 114 | color=self.fontColor, 115 | ) 116 | .move_to( 117 | (secondColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 118 | ) 119 | .shift(m.DOWN * 0.5 * (j + 1)) 120 | ) 121 | secondColumnFiles.add(text) 122 | secondColumnFilesDict[f] = text 123 | 124 | for h, f in enumerate(thirdColumnFileNames): 125 | text = ( 126 | m.MarkupText( 127 | "" 130 | + self.trim_path(f) 131 | + "" 132 | if self.command == StashSubCommand.POP 133 | else self.trim_path(f), 134 | font=self.font, 135 | font_size=24, 136 | color=self.fontColor, 137 | ) 138 | .move_to( 139 | (thirdColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) 140 | ) 141 | .shift(m.DOWN * 0.5 * (h + 1)) 142 | ) 143 | thirdColumnFiles.add(text) 144 | thirdColumnFilesDict[f] = text 145 | 146 | def populate_zones( 147 | self, 148 | firstColumnFileNames, 149 | secondColumnFileNames, 150 | thirdColumnFileNames, 151 | firstColumnArrowMap={}, 152 | secondColumnArrowMap={}, 153 | thirdColumnArrowMap={}, 154 | ): 155 | if self.command in [StashSubCommand.POP, StashSubCommand.APPLY]: 156 | try: 157 | stashedFileNames = self.repo.git.stash( 158 | "show", "--name-only", self.stash_index 159 | ) 160 | stashedFileNames = stashedFileNames.split("\n") 161 | except: 162 | print( 163 | f"git-sim error: No stash entry with index {self.stashIndex} exists in stash" 164 | ) 165 | sys.exit() 166 | for s in stashedFileNames: 167 | thirdColumnFileNames.add(s) 168 | firstColumnFileNames.add(s) 169 | thirdColumnArrowMap[s] = m.Arrow(stroke_width=3, color=self.fontColor) 170 | firstColumnFileNames.add(s) 171 | thirdColumnFileNames.add(s) 172 | thirdColumnArrowMap[s] = m.Arrow(stroke_width=3, color=self.fontColor) 173 | 174 | else: 175 | for x in self.repo.index.diff(None): 176 | firstColumnFileNames.add(x.a_path) 177 | for file in self.files: 178 | if file == x.a_path: 179 | thirdColumnFileNames.add(x.a_path) 180 | firstColumnArrowMap[x.a_path] = m.Arrow( 181 | stroke_width=3, color=self.fontColor 182 | ) 183 | 184 | for y in self.repo.index.diff("HEAD"): 185 | secondColumnFileNames.add(y.a_path) 186 | for file in self.files: 187 | if file == y.a_path: 188 | thirdColumnFileNames.add(y.a_path) 189 | secondColumnArrowMap[y.a_path] = m.Arrow( 190 | stroke_width=3, color=self.fontColor 191 | ) 192 | 193 | def parse_stash_format(self, s): 194 | # Regular expression to match either a plain integer or stash@{integer} 195 | match = re.match(r"^(?:stash@\{(\d+)\}|\b(\d+)\b)$", s) 196 | if match: 197 | # match.group(1) is the integer in the stash@{integer} format 198 | # match.group(2) is the integer if it's just a plain number 199 | # One of these groups will be None, the other will have our number as a string 200 | number_str = match.group(1) or match.group(2) 201 | return int(number_str) 202 | return None 203 | -------------------------------------------------------------------------------- /src/git_sim/status.py: -------------------------------------------------------------------------------- 1 | from git_sim.git_sim_base_command import GitSimBaseCommand 2 | from git_sim.settings import settings 3 | 4 | 5 | class Status(GitSimBaseCommand): 6 | def __init__(self): 7 | super().__init__() 8 | try: 9 | self.selected_branches.append(self.repo.active_branch.name) 10 | except TypeError: 11 | pass 12 | settings.hide_merged_branches = True 13 | self.n = self.n_default 14 | self.cmd += f"{type(self).__name__.lower()}" 15 | 16 | def construct(self): 17 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 18 | print(f"{settings.INFO_STRING} {self.cmd}") 19 | self.show_intro() 20 | self.parse_commits() 21 | self.recenter_frame() 22 | self.scale_frame() 23 | self.vsplit_frame() 24 | self.setup_and_draw_zones() 25 | self.show_command_as_title() 26 | self.fadeout() 27 | self.show_outro() 28 | -------------------------------------------------------------------------------- /src/git_sim/switch.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from argparse import Namespace 3 | 4 | import git 5 | import manim as m 6 | import numpy 7 | 8 | from git_sim.git_sim_base_command import GitSimBaseCommand 9 | from git_sim.settings import settings 10 | 11 | 12 | class Switch(GitSimBaseCommand): 13 | def __init__(self, branch: str, c: bool, detach: bool): 14 | super().__init__() 15 | self.branch = branch 16 | self.c = c 17 | self.detach = detach 18 | 19 | if self.c: 20 | if self.branch in self.repo.heads: 21 | print( 22 | "git-sim error: can't create new branch '" 23 | + self.branch 24 | + "', it already exists" 25 | ) 26 | sys.exit(1) 27 | if detach: 28 | print("git-sim error: can't use both '-c' and '--detach' flags") 29 | sys.exit(1) 30 | else: 31 | try: 32 | git.repo.fun.rev_parse(self.repo, self.branch) 33 | except git.exc.BadName: 34 | print( 35 | "git-sim error: '" 36 | + self.branch 37 | + "' is not a valid Git ref or identifier." 38 | ) 39 | sys.exit(1) 40 | 41 | if ( 42 | not self.repo.head.is_detached 43 | and self.branch == self.repo.active_branch.name 44 | ): 45 | print("git-sim error: already on branch '" + self.branch + "'") 46 | sys.exit(1) 47 | 48 | if not self.detach: 49 | if self.branch not in self.repo.heads: 50 | print("git-sim error: include --detach to allow detached HEAD") 51 | sys.exit(1) 52 | 53 | self.is_ancestor = False 54 | self.is_descendant = False 55 | 56 | # branch being switched to is behind HEAD 57 | branch_names = self.repo.git.branch("--contains", self.branch) 58 | branch_names = branch_names.split("\n") 59 | for i, bn in enumerate(branch_names): 60 | branch_names[i] = bn.strip("*").strip() 61 | branch_hexshas = [ 62 | self.repo.branches[branch].commit.hexsha for branch in branch_names 63 | ] 64 | if self.repo.head.commit.hexsha in branch_hexshas: 65 | self.is_ancestor = True 66 | 67 | # HEAD is behind branch being switched to 68 | elif self.branch in self.repo.git.branch( 69 | "--contains", self.repo.head.commit.hexsha 70 | ): 71 | self.is_descendant = True 72 | 73 | if self.branch in [branch.name for branch in self.repo.heads]: 74 | self.selected_branches.append(self.branch) 75 | 76 | try: 77 | if not self.repo.head.is_detached: 78 | self.selected_branches.append(self.repo.active_branch.name) 79 | except TypeError: 80 | pass 81 | 82 | self.cmd += f"{type(self).__name__.lower()}{' -c' if self.c else ''}{' --detach' if self.detach else ''} {self.branch}" 83 | 84 | def construct(self): 85 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 86 | print(f"{settings.INFO_STRING} {self.cmd}") 87 | 88 | self.show_intro() 89 | head_commit = self.get_commit() 90 | 91 | # using -c flag, create new branch label and exit 92 | if self.c: 93 | self.parse_commits(head_commit) 94 | self.recenter_frame() 95 | self.scale_frame() 96 | self.draw_ref(head_commit, self.topref, text=self.branch, color=m.GREEN) 97 | else: 98 | branch_commit = self.get_commit(self.branch) 99 | 100 | if self.is_ancestor: 101 | commits_in_range = list(self.repo.iter_commits(self.branch + "..HEAD")) 102 | 103 | # branch is reached from HEAD, so draw everything 104 | if len(commits_in_range) <= self.n: 105 | self.parse_commits(head_commit) 106 | reset_head_to = branch_commit.hexsha 107 | self.recenter_frame() 108 | self.scale_frame() 109 | self.reset_head(reset_head_to) 110 | self.reset_branch(head_commit.hexsha) 111 | 112 | # branch is not reached, so start from branch 113 | else: 114 | self.parse_commits(branch_commit) 115 | self.draw_ref(branch_commit, self.topref) 116 | self.recenter_frame() 117 | self.scale_frame() 118 | 119 | elif self.is_descendant: 120 | self.parse_commits(branch_commit) 121 | reset_head_to = branch_commit.hexsha 122 | self.recenter_frame() 123 | self.scale_frame() 124 | if "HEAD" in self.drawnRefs: 125 | self.reset_head(reset_head_to) 126 | if not self.repo.head.is_detached: 127 | self.reset_branch(head_commit.hexsha) 128 | else: 129 | self.draw_ref(branch_commit, self.topref) 130 | else: 131 | self.parse_commits(head_commit) 132 | self.parse_commits(branch_commit, shift=4 * m.DOWN) 133 | self.center_frame_on_commit(branch_commit) 134 | self.recenter_frame() 135 | self.scale_frame() 136 | self.reset_head(branch_commit.hexsha) 137 | self.reset_branch(head_commit.hexsha) 138 | 139 | self.color_by() 140 | self.show_command_as_title() 141 | self.fadeout() 142 | self.show_outro() 143 | -------------------------------------------------------------------------------- /src/git_sim/tag.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import manim as m 3 | 4 | from git_sim.git_sim_base_command import GitSimBaseCommand 5 | from git_sim.settings import settings 6 | 7 | 8 | class Tag(GitSimBaseCommand): 9 | def __init__(self, name: str, commit: str, d: bool): 10 | super().__init__() 11 | self.name = name 12 | self.commit = commit 13 | self.d = d 14 | 15 | if self.d: 16 | if self.commit: 17 | print( 18 | "git-sim error: can't specify commit '" 19 | + self.commit 20 | + "', when using -d flag" 21 | ) 22 | sys.exit(1) 23 | if self.name not in self.repo.tags: 24 | print( 25 | "git-sim error: can't delete tag '" 26 | + self.name 27 | + "', tag doesn't exist" 28 | ) 29 | sys.exit(1) 30 | else: 31 | if self.name in self.repo.tags: 32 | print( 33 | "git-sim error: can't create tag '" 34 | + self.name 35 | + "', tag already exists" 36 | ) 37 | sys.exit(1) 38 | 39 | self.cmd += f"{type(self).__name__.lower()}{' -d' if self.d else ''}{' self.commit' if self.commit else ''} {self.name}" 40 | 41 | def construct(self): 42 | if not settings.stdout and not settings.output_only_path and not settings.quiet: 43 | print(f"{settings.INFO_STRING} {self.cmd}") 44 | 45 | self.show_intro() 46 | self.parse_commits() 47 | self.parse_all() 48 | self.center_frame_on_commit(self.get_commit()) 49 | 50 | if not self.d: 51 | tagText = m.Text( 52 | self.name, 53 | font=self.font, 54 | font_size=20, 55 | color=self.fontColor, 56 | ) 57 | tagRec = m.Rectangle( 58 | color=m.YELLOW, 59 | fill_color=m.YELLOW, 60 | fill_opacity=0.25, 61 | height=0.4, 62 | width=tagText.width + 0.25, 63 | ) 64 | 65 | if self.commit: 66 | commit = self.repo.commit(self.commit) 67 | try: 68 | tagRec.next_to(self.drawnRefsByCommit[commit.hexsha][-1], m.UP) 69 | except KeyError: 70 | try: 71 | tagRec.next_to(self.drawnCommitIds[commit.hexsha], m.UP) 72 | except KeyError: 73 | print( 74 | "git-sim error: can't create tag '" 75 | + self.name 76 | + "' on commit '" 77 | + self.commit 78 | + "', commit not in frame" 79 | ) 80 | sys.exit(1) 81 | else: 82 | tagRec.next_to(self.topref, m.UP) 83 | tagText.move_to(tagRec.get_center()) 84 | 85 | fulltag = m.VGroup(tagRec, tagText) 86 | 87 | if settings.animate: 88 | self.play(m.Create(fulltag), run_time=1 / settings.speed) 89 | else: 90 | self.add(fulltag) 91 | 92 | self.toFadeOut.add(tagRec, tagText) 93 | self.drawnRefs[self.name] = fulltag 94 | else: 95 | fulltag = self.drawnRefs[self.name] 96 | if settings.animate: 97 | self.play(m.Uncreate(fulltag), run_time=1 / settings.speed) 98 | else: 99 | self.remove(fulltag) 100 | 101 | self.recenter_frame() 102 | self.scale_frame() 103 | self.color_by() 104 | self.show_command_as_title() 105 | self.fadeout() 106 | self.show_outro() 107 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | --- 3 | 4 | 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. 5 | 6 | ## Running tests 7 | 8 | The following instructions will let you run tests as soon as you clone the repository: 9 | 10 | ```sh 11 | $ git clone https://github.com/initialcommit-com/git-sim.git 12 | $ cd git-sim 13 | $ python3 -m venv .venv 14 | $ source venv/bin/activate 15 | (.venv)$ pip install -e . 16 | (.venv)$ pip install pytest 17 | (.venv)$ pytest -s 18 | ``` 19 | 20 | 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: 21 | 22 | ```sh 23 | (.venv)$ pytest -s 24 | ===== test session starts ========================================== 25 | platform darwin -- Python 3.11.2, pytest-7.3.2, pluggy-1.0.0 26 | rootdir: /Users/.../git-sim 27 | collected 3 items 28 | 29 | tests/e2e_tests/test_core_commands.py 30 | 31 | Temp repo directory: 32 | /private/var/folders/.../pytest-108/sample_repo0 33 | 34 | ... 35 | 36 | ===== 3 passed in 6.58s ============================================ 37 | ``` 38 | 39 | ## Helpful pytest notes 40 | 41 | - `pytest -x`: Stop after the first test fails. 42 | - `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. 43 | 44 | ## Adding more tests 45 | 46 | To add another test: 47 | 48 | - Work in `tests/e2e_tests/test_core_commands.py`. 49 | - Duplicate one of the existing test functions. 50 | - Replace the value of `raw_cmd` with the command you want to test. 51 | - Run the test suite once with `pytest -sx`. The test should fail, but it will generate the output you need to finish the process. 52 | - Look in the "Temp repo directory" specified at the start of the test output. 53 | - Find the `git-sim_media/` directory there, and find the output file that was generated for the test you just wrote. 54 | - Open that file, and make sure it's correct. 55 | - If it is, copy that file into `tests/e2e_tests/reference_files/`, with an appropriate name. 56 | - Update your new test function so that `fp_reference` points to this new reference file. 57 | - Run the test suite again, and your test should pass. 58 | - You will need to repeat this process once on macOS or Linux, and once on Windows. 59 | 60 | ## Cross-platform issues 61 | 62 | There are two cross-platform issues to be aware of. 63 | 64 | ### Inconsistent png and jpg output 65 | 66 | 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. 67 | 68 | These issues are mostly addressed by checking that image files are similar within a given threshold, rather than identical. 69 | 70 | ### Inconsistent Windows and macOS output 71 | 72 | The differences across OSes is even greater. I believe this may have something to do with which fonts are available on each system. 73 | 74 | This is dealt with by having Windows-specific reference files and by using Courier New as the font for all test reference images. 75 | -------------------------------------------------------------------------------- /tests/e2e_tests/ProggyClean.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/ProggyClean.ttf -------------------------------------------------------------------------------- /tests/e2e_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import subprocess, os 2 | from pathlib import Path 3 | from shlex import split 4 | 5 | import pytest 6 | 7 | import utils 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def tmp_repo(tmp_path_factory): 12 | """Create a copy of the sample repo, which we can run all tests against. 13 | 14 | Returns: path to tmp dir containing sample test repository. 15 | """ 16 | 17 | tmp_repo_dir = tmp_path_factory.mktemp("sample_repo") 18 | 19 | # To see where tmp_repo_dir is located, run pytest with the `-s` flag. 20 | print(f"\n\nTemp repo directory:\n {tmp_repo_dir}\n") 21 | 22 | # Create the sample repo for testing. 23 | os.chdir(tmp_repo_dir) 24 | 25 | # When defining cmd, as_posix() is required for Windows compatibility. 26 | git_dummy_path = utils.get_venv_path() / "git-dummy" 27 | cmd = f"{git_dummy_path.as_posix()} --commits=10 --branches=4 --merge=1 --constant-sha --name=sample_repo --diverge-at=2" 28 | cmd_parts = split(cmd) 29 | subprocess.run(cmd_parts) 30 | 31 | return tmp_repo_dir / "sample_repo" 32 | -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-add.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-branch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-branch.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-checkout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-checkout.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-cherry_pick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-cherry_pick.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-clean.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-commit.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-log.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-merge.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-mv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-mv.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-rebase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-rebase.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-reset.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-restore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-restore.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-revert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-revert.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-rm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-rm.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-stash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-stash.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-status.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-switch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-switch.png -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/git-sim-tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/initialcommit-com/git-sim/cc165a18786b3d19d1e065d3ca71d0299fb671a6/tests/e2e_tests/reference_files/git-sim-tag.png -------------------------------------------------------------------------------- /tests/e2e_tests/test_core_commands.py: -------------------------------------------------------------------------------- 1 | """Tests for the core commands implemented in git-sim. 2 | 3 | All test runs use the -d flag to prevent images from opening automatically. 4 | 5 | To induce failure, include a call to `run_git_reset()` in one of the 6 | test functions. 7 | """ 8 | 9 | import os, subprocess 10 | from pathlib import Path 11 | 12 | from utils import get_cmd_parts, compare_images, run_git_reset 13 | 14 | import pytest 15 | 16 | 17 | git_sim_commands = [ 18 | # Simple commands. 19 | "git-sim add", 20 | "git-sim log", 21 | "git-sim clean", 22 | "git-sim commit", 23 | "git-sim restore", 24 | "git-sim stash", 25 | "git-sim status", 26 | # Complex commands. 27 | "git-sim branch new_branch", 28 | "git-sim checkout branch2", 29 | "git-sim cherry-pick branch2", 30 | "git-sim merge branch2", 31 | "git-sim mv main.1 main.100", 32 | "git-sim rebase branch2", 33 | "git-sim reset HEAD^", 34 | "git-sim revert HEAD^", 35 | "git-sim rm main.1", 36 | "git-sim switch branch2", 37 | "git-sim tag new_tag", 38 | ] 39 | 40 | 41 | @pytest.mark.parametrize("raw_cmd", git_sim_commands) 42 | def test_command(tmp_repo, raw_cmd): 43 | """Test a git-sim command. 44 | 45 | This function works for any command of the forms 46 | `git-sim ` 48 | """ 49 | 50 | # Generate the string to look for in the filename. 51 | # `git-sim log` -> "git-sim-log" 52 | # `git-sim cherry-pick branch2` -> "git-sim-cherry_pick"" 53 | raw_cmd_parts = raw_cmd.split(" ") 54 | filename_element = f"git-sim-{raw_cmd_parts[1].replace('-', '_')}" 55 | 56 | # Get version of the command needed for testing, and run command. 57 | cmd_parts = get_cmd_parts(raw_cmd) 58 | os.chdir(tmp_repo) 59 | output = subprocess.run(cmd_parts, capture_output=True) 60 | 61 | # Get file paths to generated and reference files. 62 | fp_generated = Path(output.stdout.decode().strip()) 63 | fp_reference = Path(__file__).parent / f"reference_files/{filename_element}.png" 64 | 65 | # Validate filename elements, and compare output image to reference image. 66 | assert filename_element in str(fp_generated) 67 | compare_images(fp_generated, fp_reference) 68 | -------------------------------------------------------------------------------- /tests/e2e_tests/utils.py: -------------------------------------------------------------------------------- 1 | import os, subprocess 2 | from pathlib import Path 3 | from shlex import split 4 | 5 | import numpy as np 6 | 7 | from PIL import Image, ImageChops 8 | 9 | 10 | def compare_images(path_gen, path_ref): 11 | """Compare a generated image against a reference image. 12 | 13 | This is a simple pixel-by-pixel comparison, with a threshold for 14 | an allowable difference. 15 | 16 | Parameters: file path to generated and reference image files 17 | Returns: True/ False 18 | """ 19 | # Verify that the path to the generated file exists. 20 | assert ".png" in str(path_gen) 21 | assert path_gen.exists() 22 | 23 | img_gen = Image.open(path_gen) 24 | img_ref = Image.open(path_ref) 25 | 26 | img_diff = ImageChops.difference(img_gen, img_ref) 27 | 28 | # We're only concerned with pixels that differ by a total of 20 or more 29 | # over all RGB values. 30 | # Convert the image data to a NumPy array for processing. 31 | data_diff = np.array(img_diff) 32 | 33 | # Calculate the sum along the color axis (axis 2) and then check 34 | # if the sum is greater than or equal to 20. This will return a 2D 35 | # boolean array where True represents pixels that differ significantly. 36 | pixels_diff = np.sum(data_diff, axis=2) >= 20 37 | 38 | # Calculate the ratio of pixels that differ significantly. 39 | ratio_diff = np.mean(pixels_diff) 40 | 41 | # Images are similar if only a small % of pixels differ significantly. 42 | # This value can be increased if tests are failing when they shouldn't. 43 | # It can be decreased if tests are passing when they shouldn't. 44 | msg = f"bad pixel ratio ({path_ref.stem[8:]}): {ratio_diff}" 45 | assert ratio_diff < 0.015, msg 46 | 47 | 48 | def get_cmd_parts(raw_command): 49 | """ 50 | Convert a raw git-sim command to the full version we need to use 51 | when testing, then split the full command into parts for use in 52 | subprocess.run(). This allows test functions to explicitly state 53 | the actual command that users would run. 54 | 55 | For example, the command: 56 | `git-sim log` 57 | becomes: 58 | ` -d --output-only-path --img-format=png --font="/path/to/test/font.ttf" log` 59 | 60 | This prevents images from auto-opening, simplifies parsing output to 61 | identify the images we need to check, and prefers png for test runs. 62 | 63 | Returns: list of command parts, ready to be run with subprocess.run() 64 | """ 65 | # Add the global flags needed for testing. 66 | font_path = Path(__file__).parent / "ProggyClean.ttf" 67 | cmd = raw_command.replace( 68 | "git-sim", 69 | f"git-sim -d --output-only-path --img-format=png --font='{font_path}'", 70 | ) 71 | 72 | # Replace `git-sim` with the full path to the binary. 73 | # as_posix() is needed for Windows compatibility. 74 | # The space is included in "git-sim " to avoid replacing any occurrences 75 | # of git-sim in a font path. 76 | git_sim_path = get_venv_path() / "git-sim" 77 | cmd = cmd.replace("git-sim ", f"{git_sim_path.as_posix()} ") 78 | 79 | # Show full test command when run in diagnostic mode. 80 | print(f" Test command: {cmd}") 81 | 82 | return split(cmd) 83 | 84 | 85 | def run_git_reset(tmp_repo): 86 | """Run `git reset`, in order to induce a failure. 87 | 88 | This is particularly useful when testing the image comparison algorithm. 89 | - Running `git reset` makes many of the generated images different. 90 | - For example, `git-sim log` then generates a valid image, but it doesn't 91 | match the reference image. 92 | 93 | Note: tmp_repo is a required argument, to make sure this command is not 94 | accidentally called in a different directory. 95 | """ 96 | cmd = "git reset --hard 60bce95465a890960adcacdcd7fa726d6fad4cf3" 97 | cmd_parts = split(cmd) 98 | 99 | os.chdir(tmp_repo) 100 | subprocess.run(cmd_parts) 101 | 102 | 103 | def get_venv_path(): 104 | """Get the path to the active virtual environment. 105 | 106 | We actually need the bin/ or Scripts/ dir, not just the path to venv/. 107 | """ 108 | if os.name == "nt": 109 | return Path(os.environ.get("VIRTUAL_ENV")) / "Scripts" 110 | else: 111 | return Path(os.environ.get("VIRTUAL_ENV")) / "bin" 112 | -------------------------------------------------------------------------------- /tests/unit_tests/test.py: -------------------------------------------------------------------------------- 1 | import unittest, git, argparse 2 | from manim import * 3 | 4 | 5 | class TestGitSim(unittest.TestCase): 6 | def test_git_sim(self): 7 | """Test git sim.""" 8 | 9 | self.assertEqual(1, 1) 10 | 11 | 12 | if __name__ == "__main__": 13 | unittest.main() 14 | --------------------------------------------------------------------------------