├── .flake8 ├── .github └── workflows │ ├── coverage.yaml │ ├── lint.yml │ ├── publish_aur.yaml │ ├── publish_pypi.yaml │ └── tests.yaml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── conf.py ├── cookbook.rst ├── encryption.rst ├── filelist.rst ├── getting_started.rst ├── index.rst ├── installation.rst ├── migration_v1.rst └── usage.rst ├── dotgit ├── __init__.py ├── __main__.py ├── args.py ├── calc_ops.py ├── checks.py ├── enums.py ├── file_ops.py ├── flists.py ├── git.py ├── info.py ├── plugin.py └── plugins │ ├── __init__.py │ ├── encrypt.py │ └── plain.py ├── makefile ├── old ├── README.md ├── bin │ ├── bash_completion │ ├── dotgit │ ├── dotgit_headers │ │ ├── clean │ │ ├── diff │ │ ├── help │ │ ├── help.txt │ │ ├── repo │ │ ├── restore │ │ ├── security │ │ └── update │ └── fish_completion.fish ├── build │ ├── arch │ │ └── PKGBUILD │ └── debian │ │ ├── changelog │ │ ├── compat │ │ ├── control │ │ ├── install │ │ ├── postinst │ │ └── rules └── dotgit.sh ├── pkg ├── arch │ └── PKGBUILD └── completion │ ├── bash.sh │ └── fish.fish ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_args.py ├── test_calc_ops.py ├── test_checks.py ├── test_file_ops.py ├── test_flists.py ├── test_git.py ├── test_info.py ├── test_integration.py ├── test_main.py ├── test_plugins_encrypt.py └── test_plugins_plain.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E402 3 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | coverage: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Setup Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | 19 | - name: Install testing and coverage dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pytest pytest-cov coveralls 23 | 24 | - name: Run unit tests 25 | run: python3 -m pytest --cov=dotgit 26 | 27 | - name: Upload coverage report to Coveralls 28 | env: 29 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 30 | run: coveralls 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.x' 20 | 21 | - name: Install linting dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install flake8 25 | 26 | - name: Run linting tests 27 | run: flake8 dotgit --count --statistics --show-source 28 | -------------------------------------------------------------------------------- /.github/workflows/publish_aur.yaml: -------------------------------------------------------------------------------- 1 | name: publish-aur 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | arch: 9 | runs-on: ubuntu-latest 10 | container: archlinux:base-devel 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | path: repo 16 | 17 | # install needed packages to update aur repo 18 | - run: | 19 | pacman -Sy --noconfirm git openssh 20 | 21 | # setup ssh credentials 22 | - run: | 23 | mkdir -p /root/.ssh 24 | echo "$AUR_SSH_KEY" > /root/.ssh/id_rsa 25 | chmod 600 /root/.ssh/id_rsa 26 | echo "$AUR_FINGERPRINT" > /root/.ssh/known_hosts 27 | shell: bash 28 | env: 29 | AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} 30 | AUR_FINGERPRINT: ${{ secrets.AUR_FINGERPRINT }} 31 | 32 | # clone aur repo, update PKGBUILD and .SRCINFO and commit + push changes 33 | - run: | 34 | git config --global user.name "Github Actions" 35 | git config --global user.email "github-actions@dotgit.com" 36 | git clone "ssh://aur@aur.archlinux.org/$PKG_NAME.git" 37 | cp repo/pkg/arch/PKGBUILD "$PKG_NAME" 38 | chown -R "nobody:nobody" "$PKG_NAME" 39 | cd "$PKG_NAME" 40 | sudo -u nobody makepkg --printsrcinfo > .SRCINFO 41 | git add PKGBUILD .SRCINFO 42 | git commit -m "version bump" 43 | git push 44 | shell: bash 45 | env: 46 | PKG_NAME: dotgit 47 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: publish-pypi 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | pypi: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: '3.x' 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | 23 | - name: Build package 24 | run: python setup.py sdist bdist_wheel 25 | 26 | - name: Upload package 27 | env: 28 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: twine upload dist/* 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | runs-on: '${{ matrix.os }}' 12 | 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | python-version: [3.6, 3.7, 3.8, 3.9] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Setup Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install testing dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install pytest 30 | 31 | - name: Run unit tests 32 | run: python3 -m pytest 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.swp 3 | *.egg-info 4 | build 5 | dist 6 | docs/_build 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | -------------------------------------------------------------------------------- /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 | {description} 294 | Copyright (C) {year} {fullname} 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 | {signature of Ty Coon}, 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 | 341 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pkg/completion * 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dotgit 2 | 3 | ![tests](https://github.com/kobus-v-schoor/dotgit/workflows/tests/badge.svg) 4 | [![coverage](https://coveralls.io/repos/github/kobus-v-schoor/dotgit/badge.svg)](https://coveralls.io/github/kobus-v-schoor/dotgit) 5 | ![publish-pypi](https://github.com/kobus-v-schoor/dotgit/workflows/publish-pypi/badge.svg) 6 | [![docs](https://readthedocs.org/projects/dotgit/badge/?version=latest)](https://dotgit.readthedocs.io/en/latest/) 7 | [![downloads](https://img.shields.io/pypi/dm/dotgit)](https://pypi.org/project/dotgit/) 8 | 9 | ## A comprehensive and versatile dotfiles manager 10 | 11 | dotgit allows you to easily store, organize and manage all your dotfiles for 12 | any number of machines. Written in python with no external dependencies besides 13 | git, it works on both Linux and MacOS (should also work on other \*nix 14 | environments). 15 | 16 | ## Project goals 17 | 18 | * Share files between machines or keep separate versions, all in the same repo 19 | without any funny shenanigans 20 | * Make use of an intuitive filelist which makes organization easy 21 | * Make git version control convenient and easy to use 22 | 23 | ## Why use dotgit? 24 | 25 | * You can very easily organize and categorize your dotfiles, making it easy to 26 | store different setups in the same repo (e.g. your workstation and your 27 | headless server dotfiles, stored and managed together) 28 | * Ease-of-use is baked into everything without hindering more advanced users. 29 | For instance, dotgit can automatically commit and push commits for you should 30 | you want it to, but you can just as easily make the commits yourself 31 | * dotgit has an automated test suite that tests its functionality with several 32 | versions of Python on Linux and MacOS to ensure cross-platform compatibility 33 | * Support for both symlinking or copying dotfiles to your home directory. 34 | Copying allows you to quickly bootstrap a machine without leaving your repo 35 | or dotgit on it 36 | * No external dependencies apart from git allowing you to install and use 37 | dotgit easily in any environment that supports Python 38 | * Encryption using GnuPG supported to allow you to store sensitive dotfiles 39 | 40 | ## Getting started 41 | 42 | To get started with dotgit have a look at dotgit's documentation at 43 | [https://dotgit.readthedocs.io/](https://dotgit.readthedocs.io/). 44 | 45 | ## Future goals 46 | 47 | The following features are on the wishlist for future releases (more 48 | suggestions welcome): 49 | 50 | * [x] Encryption using GnuPG 51 | * [ ] Config file for default behaviour (e.g. verbosity level, hard mode) 52 | * [ ] Templating 53 | 54 | ## Migration from v1.x 55 | 56 | If you used the previous bash version of dotgit (pre-v2) you need to follow the 57 | migration guide 58 | [here](https://dotgit.readthedocs.io/en/latest/migration_v1.html) to make your 59 | dotfiles repo compatible with the new version. 60 | 61 | ## Contributing 62 | 63 | Contributions to dotgit are welcome, just open a PR here on the repo. Please 64 | note that your contributions should be linted with Flake8 (you can check for 65 | linting errors locally by running `make lint` in the repo) and should also be 66 | covered using unit tests using the pytest framework. 67 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | import dotgit.info as info 19 | from datetime import datetime 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'dotgit' 25 | copyright = f'{datetime.now().year}, {info.__author__}' 26 | author = info.__author__ 27 | 28 | # The short X.Y version 29 | version = info.__version__ 30 | # The full version, including alpha/beta/rc tags 31 | release = '' 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # 38 | # needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | 'sphinx_rtd_theme', 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # The suffix(es) of source filenames. 51 | # You can specify multiple suffix as a list of string: 52 | # 53 | # source_suffix = ['.rst', '.md'] 54 | source_suffix = '.rst' 55 | 56 | # The master toctree document. 57 | master_doc = 'index' 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | # 62 | # This is also used if you do content translation via gettext catalogs. 63 | # Usually you set "language" from the command line for these cases. 64 | language = None 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | # This pattern also affects html_static_path and html_extra_path. 69 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 70 | 71 | # The name of the Pygments (syntax highlighting) style to use. 72 | pygments_style = None 73 | 74 | 75 | # -- Options for HTML output ------------------------------------------------- 76 | 77 | # The theme to use for HTML and HTML Help pages. See the documentation for 78 | # a list of builtin themes. 79 | # 80 | html_theme = 'sphinx_rtd_theme' 81 | 82 | # Theme options are theme-specific and customize the look and feel of a theme 83 | # further. For a list of options available for each theme, see the 84 | # documentation. 85 | # 86 | # html_theme_options = {} 87 | 88 | # Add any paths that contain custom static files (such as style sheets) here, 89 | # relative to this directory. They are copied after the builtin static files, 90 | # so a file named "default.css" will overwrite the builtin "default.css". 91 | html_static_path = ['_static'] 92 | 93 | # Custom sidebar templates, must be a dictionary that maps document names 94 | # to template names. 95 | # 96 | # The default sidebars (for documents that don't match any pattern) are 97 | # defined by theme itself. Builtin themes are using these templates by 98 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 99 | # 'searchbox.html']``. 100 | # 101 | # html_sidebars = {} 102 | 103 | 104 | # -- Options for HTMLHelp output --------------------------------------------- 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'dotgitdoc' 108 | 109 | 110 | # -- Options for LaTeX output ------------------------------------------------ 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'dotgit.tex', 'dotgit Documentation', 135 | 'Kobus van Schoor', 'manual'), 136 | ] 137 | 138 | 139 | # -- Options for manual page output ------------------------------------------ 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [ 144 | (master_doc, 'dotgit', 'dotgit Documentation', 145 | [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ---------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | (master_doc, 'dotgit', 'dotgit Documentation', 156 | author, 'dotgit', 'One line description of project.', 157 | 'Miscellaneous'), 158 | ] 159 | 160 | 161 | # -- Options for Epub output ------------------------------------------------- 162 | 163 | # Bibliographic Dublin Core info. 164 | epub_title = project 165 | 166 | # The unique identifier of the text. This can be a ISBN number 167 | # or the project homepage. 168 | # 169 | # epub_identifier = '' 170 | 171 | # A unique identification for the text. 172 | # 173 | # epub_uid = '' 174 | 175 | # A list of files that should not be packed into the epub file. 176 | epub_exclude_files = ['search.html'] 177 | -------------------------------------------------------------------------------- /docs/cookbook.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Cookbook 3 | ======== 4 | 5 | This cookbook presents an approach to manage your filelist in such a way to 6 | make your dotfiles management convenient and well-organized. There is obviously 7 | many other ways to manage your filelist but this is my personal favourite as it 8 | makes it easy to add or remove a group of dotfiles for a host and it still 9 | provides a lot of flexibility. 10 | 11 | The main idea is to define groups that match your hostnames and choose 12 | categories that groups similar dotfiles (for example a "vim" or "tmux" 13 | category). 14 | 15 | An example filelist that follows this approach would look something like this:: 16 | 17 | # group names matches the hosts you manage 18 | laptop=vim,tmux,x,tools 19 | desktop=vim,tmux,x 20 | 21 | # vim category 22 | .vimrc:vim 23 | 24 | # tmux category 25 | .tmux.conf:tmux 26 | 27 | # x category 28 | .xinitrc:x 29 | .Xresources:x 30 | 31 | # tools category 32 | .bin/hack_nsa.sh:tools 33 | .bin/change_btc_price.sh:tools 34 | 35 | # these files are managed manually per-host 36 | .inputrc:laptop 37 | .inputrc:desktop 38 | 39 | This way you can easily add or remove a group of dotfiles for a host by simply 40 | editing their group. And since the group name matches your hostname you don't 41 | need to manually specify any categories when running dotgit commands (have a 42 | look at the :doc:`usage` section to see why). 43 | -------------------------------------------------------------------------------- /docs/encryption.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Encryption 3 | ========== 4 | 5 | dotgit allows you to encrypt files that you don't want to be stored in 6 | plaintext in your repo. This is achieved by encrypting the files with GnuPG 7 | with its default symmetric encryption (AES256 on my machine at the time of 8 | writing) before storing them in your repo. You can specify that a file should 9 | be encrypted by appending ``|encrypt`` to the filename in your filelist, for 10 | example:: 11 | 12 | .ssh/config|encrypt 13 | 14 | When using encryption you need to take note of the following: 15 | 16 | * Encrypted files are not directly linked to your dotfiles repository. This 17 | means you need to run ``dotgit update`` whenever you want to save changes you 18 | made to the files in your repo. 19 | * Your encryption password is securely hashed and stored in your repository. 20 | While this hash is secure in theory (for implementation details see below) 21 | it's probably not a good idea to just leave this lying around in a public 22 | repo somewhere. 23 | 24 | For those interested, the password is hashed using Python's hashlib library 25 | using 26 | 27 | * PKCS#5 function 2 key-derivation algorithm 28 | * 32-bits of salt 29 | * 100000 iterations of the SHA256 hash 30 | 31 | When you add an encrypted dotfile to your repo for the first time dotgit will 32 | ask you for a new encryption password. Thereafter, whenever you want to 33 | ``update`` or ``restore`` an encrypted file you will need to provide the same 34 | encryption password. You can change your encryption password by running the 35 | ``passwd`` command. 36 | -------------------------------------------------------------------------------- /docs/filelist.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Filelist syntax 3 | =============== 4 | 5 | Your filelist is where all the magic happens, and correctly using dotgit's 6 | categories and groups will make your life (or at least your dotfile management) 7 | a lot easier. 8 | 9 | The basic syntax is as follows: 10 | 11 | * Blank lines are ignored 12 | * Files starting with ``#`` are ignored (comments) 13 | * All other lines are treated as filenames or group definitions 14 | 15 | Filenames 16 | ========== 17 | 18 | All the non-group lines (the group lines are the ones with a ``=`` in them) are 19 | files that you want to store in your dotfiles repo. The filenames 20 | 21 | * is everything up to a ``:`` or ``|`` character (or new line if those aren't 22 | present) 23 | * can contain spaces 24 | * is relative to your home directory 25 | 26 | Categories 27 | ========== 28 | 29 | When you specify a filename you can specify one or more "categories" that it 30 | should belong to. These categories act like tags and can be anything you want 31 | to group your dotfiles by. If a filename does not have a category specified it 32 | is automatically added to the ``common`` category. Categories are specified in 33 | the following way:: 34 | 35 | # no category, automatically added to the "common" category 36 | .bashrc 37 | # added to the "tools" category 38 | .tmux.conf:tools 39 | # added to the "tools" and "vim" category 40 | .vimrc:tools,vim 41 | 42 | When more than one category is specified for a file the file is linked between 43 | the categories. This means that changes to the file will affect both 44 | categories. This is very useful if for example you want to share a file between 45 | two hosts:: 46 | 47 | .vimrc:laptop,desktop 48 | 49 | You can also store separate versions of a file by storing the different 50 | versions under different categories:: 51 | 52 | .vimrc:laptop 53 | .vimrc:desktop 54 | 55 | Groups 56 | ====== 57 | 58 | Groups allow you to group multiple categories which makes working with multiple 59 | categories a lot easier. They are defined using the following syntax:: 60 | 61 | group=category1,category2 62 | 63 | Along with dotgit's automatic hostname category (see the :doc:`usage` section 64 | for more details) groups become very useful. Have a look at the 65 | :doc:`cookbook` section for how this could be used. 66 | 67 | Plugins 68 | ======= 69 | 70 | Plugins allow you to go beyond dotgit's normal symlinking of dotfiles. 71 | Currently dotgit only has one plugin named ``encrypt``, which allows you to 72 | encrypt your dotfiles using GnuPG. Plugins are specified using the ``|`` 73 | character:: 74 | 75 | # no categories with a plugin 76 | .ssh/config|encrypt 77 | # using categories with a plugin 78 | .ssh/config:laptop,desktop|encrypt 79 | 80 | Only one plugin can be chosen at a time and if categories are specified they 81 | must be specified before the plugin. 82 | 83 | Putting it all together 84 | ======================= 85 | 86 | An example filelist might look something like this:: 87 | 88 | # grouping makes organizing categories easy - check the "Cookbook" section 89 | # for a good way to utilize groups 90 | laptop=tools,x,ssh 91 | desktop=tools,x 92 | 93 | # this file will be added to the "common" category automatically 94 | .bashrc 95 | 96 | # this file belongs to the "x" category 97 | .xinitrc:x 98 | 99 | # sharing/splitting of dotfiles between hosts/categories 100 | .vimrc:tools,vim 101 | .vimrc:pi 102 | 103 | # here the "encrypt" plugin is used to encrypt these files 104 | .ssh/id_rsa:ssh|encrypt 105 | .ssh/id_rsa.pub:ssh|encrypt 106 | .gitconfig|encrypt 107 | 108 | # this file will only ever get used if you have a host with the name 109 | # "server" or if you explicitly activate the "server" category 110 | .foo:server 111 | 112 | You can also have a look at an example dotfiles repo 113 | `here `_. 114 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | Setting up your first dotgit repo 6 | ================================= 7 | 8 | Before starting you will need to choose a location to store your dotfiles. This 9 | should be a separate folder from your home directory, for example 10 | ``~/.dotfiles``. It is also assumed that you have already installed dotgit. If 11 | not, head on over to the :doc:`installation` section first. 12 | 13 | You will probably want to store your dotfiles on some online hosting platform 14 | like GitHub. If so, firstly go and create a new repository on that platform. 15 | Clone the repository to your chosen dotfiles location and ``cd`` into it:: 16 | 17 | git clone https://github.com/username/dotfiles ~/.dotfiles 18 | cd ~/.dotfiles 19 | 20 | From this point onward it is assumed that you are running all of the commands 21 | while inside your dotgit repo. Whenever you want to set up a new dotgit repo 22 | you first need to initialize it. To do that, run the ``init`` command:: 23 | 24 | dotgit init -v 25 | 26 | Running this will create your filelist (unsurprisingly in a file named 27 | ``filelist``) for you. Your filelist will contain all the dotfiles you want to 28 | store inside your dotgit repo, as well as what plugins and categories you want 29 | them to belong to (check out the :doc:`filelist` section for more info on 30 | those). For now, we'll just add your bash config file to your repo. Note that 31 | the path is relative to your home directory, and as such you only specify 32 | ``.bashrc`` and not its full path:: 33 | 34 | echo .bashrc >> filelist 35 | 36 | Now that you have made changes to your filelist you need to update your repo. 37 | This will copy over your files to your dotgit repo and set up the links in your 38 | home folder pointing to them. To do so, run the ``update`` command:: 39 | 40 | dotgit update -v 41 | 42 | The ``update`` command does two things. Firstly it copies your file from your 43 | home directory into your dotfiles repo and then it creates a symlink in your 44 | home folder that links to this file. Your dotfiles repo will now look something 45 | like this:: 46 | 47 | ~/.dotfiles 48 | ├── dotfiles 49 | │   └── plain 50 | │   └── common 51 | │   └── .bashrc 52 | └── filelist 53 | 54 | And in your home folder you should see a symlink to your dotfiles repo:: 55 | 56 | readlink ~/.bashrc 57 | /home/user/.dotfiles/dotfiles/plain/common/.bashrc 58 | 59 | To commit your changes you can either do so by using git directly or making use 60 | of dotgit's convenient ``commit`` command:: 61 | 62 | dotgit commit -v 63 | 64 | This will commit all your changes and also generate a meaningful commit 65 | message, and if your repo has a remote it will also ask if it should push your 66 | changes to it. Note that you never need to use dotgit's git capabilities, your 67 | dotgit repo is just a plain git repo and the git commands are merely there for 68 | convenience. If you want to go ahead and set up some crazy git hooks or make 69 | use of branches and tags you are welcome to do so, dotgit won't get in your 70 | way. 71 | 72 | Example workflow for multiple hosts 73 | =================================== 74 | 75 | In this example we will set up two machines to use dotgit. The first will be 76 | named "laptop" and the second "desktop". We want to share a ".vimrc" file 77 | between the two but have separate ".xinitrc" files. Note that this example 78 | doesn't follow the recommended filelist structure as outlined in the 79 | :doc:`cookbook`, but is merely set up as an example. 80 | 81 | First we start on the laptop. On it we have the ".vimrc" file that we want to 82 | share as well as the ".xinitrc" file for the laptop. We create a new dotgit 83 | repo (cloning an empty repo or just making an empty directory) and initialize 84 | the repo by running the ``init`` command inside the repo:: 85 | 86 | [laptop]$ dotgit init 87 | 88 | This command creates an empty filelist and also makes the first commit inside 89 | the repo. Next, we set up our filelist. We will set up the complete filelist 90 | now, since the ".xinitrc" file for the desktop won't be affected while we work 91 | on the laptop (since it is in a separate category). We edit the filelist to 92 | look as follows:: 93 | 94 | # dotgit filelist 95 | .vimrc:laptop,desktop 96 | .xinitrc:laptop 97 | .xinitrc:desktop 98 | 99 | Our filelist is now ready. To update the dotgit repo it we run the update 100 | command inside the dotgit repo:: 101 | 102 | [laptop]$ dotgit update -v 103 | 104 | Our repository now contains the newly-copied ".vimrc" file as well as the 105 | ".xinitrc" file for the laptop. To see these changes, we can run the ``diff`` 106 | command:: 107 | 108 | [laptop]$ dotgit diff 109 | 110 | We are now done on the laptop, so we commit our changes to the repo and push it 111 | to the remote (something like GitHub):: 112 | 113 | [laptop]$ dotgit commit 114 | 115 | Next, on the desktop we clone the repo to where we want to save it. Assuming 116 | that dotgit is already installed on the desktop we cd into the dotfiles repo. 117 | We first want to replace the ".vimrc" on the desktop with the one stored in the 118 | repo, so we run the ``restore`` command inside the repo:: 119 | 120 | [desktop]$ dotgit restore -v 121 | 122 | .. note:: 123 | When you run the ``update`` command dotgit will replace any files in the 124 | repo with those in your home folder. This is why we first ran the 125 | ``restore`` command in the previous step, otherwise the ".vimrc" that might 126 | have already been present on the desktop would have replaced the one in the 127 | repo. 128 | 129 | We now want to store the ".xinitrc" file from the desktop in our dotgit repo, 130 | so again we run the update command:: 131 | 132 | [desktop]$ dotgit update -v 133 | 134 | We then save changes to the dotfiles repo by committing it and pushing it to 135 | the remote:: 136 | 137 | [desktop]$ dotgit commit 138 | 139 | Now we're done! The repo now contains the ".vimrc" as well as the two 140 | ".xinitrc" files from the desktop and laptop. In the future, if you made 141 | changes to your ".vimrc" file on your laptop you would commit and push it, and 142 | then run ``git pull`` on the desktop to get the changes on the desktop as well. 143 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | dotgit documentation 2 | ==================== 3 | 4 | dotgit allows you to easily store, organize and manage all your dotfiles for 5 | any number of machines. Written in python with no external dependencies besides 6 | git, it works on both Linux and MacOS (should also work on other \*nix 7 | environments). This is dotgit's official documentation. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | installation 14 | getting_started 15 | filelist 16 | usage 17 | encryption 18 | cookbook 19 | migration_v1 20 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | System package manager 6 | ====================== 7 | 8 | * Arch Linux: `AUR package `_ 9 | 10 | Install using pip 11 | ================= 12 | 13 | The easiest method to install dotgit is using pip (you might need to change the 14 | command to ``pip3`` depending on your system):: 15 | 16 | pip install -U dotgit 17 | 18 | If you are installing dotgit using pip make sure to check out the `Shell 19 | completion`_ section to get tab-completion working. 20 | 21 | Shell completion 22 | ================ 23 | 24 | If you did not install dotgit using the system package manager you can get 25 | shell completion (tab-completion) working by installing the relevant dotgit 26 | completion scripts for your shell. 27 | 28 | Bash:: 29 | 30 | url="https://raw.githubusercontent.com/kobus-v-schoor/dotgit/master/pkg/completion/bash.sh" 31 | curl "$url" >> ~/.bash_completion 32 | 33 | Fish shell:: 34 | 35 | url="https://raw.githubusercontent.com/kobus-v-schoor/dotgit/master/pkg/completion/fish.fish" 36 | curl --create-dirs "$url" >> ~/.config/fish/completions/dotgit.fish 37 | 38 | Any help for non-bash completion scripts would be much appreciated :) 39 | 40 | Manual installation 41 | =================== 42 | 43 | If you do not want to install dotgit with a package manager you can also just 44 | add this repo as a git submodule to your dotfiles repo. That way you get dotgit 45 | whenever you clone your dotfiles repo with no install necessary. Note that if 46 | you choose this route you will need to manually update dotgit to the newest 47 | version if there is a new release by pulling in the newest changes into your 48 | repo. To set this up, cd into your dotfiles repo and run the following:: 49 | 50 | cd ~/.dotfiles 51 | git submodule add https://github.com/kobus-v-schoor/dotgit 52 | git commit -m "Added dotgit submodule" 53 | 54 | 55 | Now, whenever you clone your dotfiles repo you will have to pass an additional 56 | flag to git to tell it to also clone the dotgit repo:: 57 | 58 | git clone --recurse-submodules https://github.com/dotfiles/repo ~/.dotfiles 59 | 60 | If you want to update the dotgit repo to the latest version run the following 61 | inside your dotfiles repo:: 62 | 63 | git submodule update --remote dotgit 64 | git commit -m "Updated dotgit" 65 | 66 | Finally, to run dotgit it is easiest to set up something like an alias. You can 67 | then also set up the bash completion in the same way as mentioned in `Shell 68 | completion`_. This is an example entry of what you might want to put in your 69 | ``.bashrc`` file to make an alias (you'll probably want to update the path and 70 | ``python3`` command to match your setup):: 71 | 72 | alias dotgit="python3 ~/.dotfiles/dotgit/dotgit/__main__.py" 73 | -------------------------------------------------------------------------------- /docs/migration_v1.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Migration from v1.x 3 | =================== 4 | 5 | Reasons for rewriting 6 | ===================== 7 | 8 | After many years dotgit was finally completely rewritten in python. The first 9 | version was written in pure bash, and while this was appealing at first it 10 | quickly became a nightmare from a maintenance point-of-view. The new python 11 | rewrite comes with many advantages including: 12 | 13 | * Much better cross-platform compatibility, especially for MacOS and friends. 14 | Using utilities like ``find`` became problematic between different 15 | environments 16 | * A fully automated test suite to test dotgit on both Linux and MacOS 17 | * Code that the author can understand after not seeing it for a week 18 | * Unified install method (pip) for all the platforms 19 | 20 | Differences between the old and the new 21 | ======================================= 22 | 23 | After much consideration it was decided to rather to not re-implement the 24 | directory support, which is the only major change functionality wise from the 25 | first version. It requires a lot of special treatment that breaks some of the 26 | logic that works very well for single files which lead to weird bugs and 27 | behaviour in the first version. Excluding it made the file-handling logic much 28 | more robust and the behaviour surrounding the handling of files is much more 29 | predictable. 30 | 31 | Sticking with the old version 32 | ============================= 33 | 34 | Should you decide you'd like to stick to the old version of dotgit, you are 35 | welcome to do so. Installing the pip package will also make the original dotgit 36 | available as the command ``dotgit.sh`` (AUR package includes this as well). 37 | Please note that I will not be able to support the old version anymore, and as 38 | such you're on your own if you decide to use the old version. 39 | 40 | Migrating to the new version 41 | ============================ 42 | 43 | To make room for future improvements, the layout of the dotgit dotfiles repos 44 | had to change. Unfortunately this means that the new repos are not directly 45 | compatible with the old ones, although it is easy to migrate to the new 46 | version's format. To do so, do the following: 47 | 48 | 1. Firstly, backup your current dotfiles repo in case something goes wrong 49 | 2. Next, do a hard restore using the old dotgit so that it copies all your 50 | files from your repo to your home folder using ``dotgit.sh hard-restore`` 51 | 3. Now, delete your old dotgit files inside your repo as well as your 52 | cryptlist (which signals to dotgit that you are using the old version) using 53 | ``rm -rf dotfiles dmz cryptlist passwd``. Encrypted files are now specified 54 | using the new plugin syntax (see :doc:`filelist`), so add them to your 55 | original filelist using the new syntax. 56 | 4. With the new version of dotgit, first run ``dotgit init -v`` and then run 57 | ``dotgit update -v``. This will store the files from your home folder back 58 | in your repo in their new locations. If you have encrypted files this will 59 | also ask for your new encryption password 60 | 5. Commit the changes to your repo using either git or ``dotgit commit`` 61 | 6. Familiarize yourself with the new dotgit command-line interface which has 62 | changed slightly to better follow conventions commonly found on the 63 | command-line by having a look at the usage section in ``dotgit -h`` 64 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | The basic usage syntax looks like this:: 6 | 7 | dotgit [flags] {action} [category [category]] 8 | 9 | Where ``action`` is one of the actions listed below and ``category`` is one or 10 | more categories or groups to activate. If no categories are specified dotgit 11 | will automatically activate the ``common`` category as well as a category with 12 | your machine's hostname. 13 | 14 | Using categories 15 | ================ 16 | 17 | When you run dotgit all of its actions will be limited to the categories that 18 | are activated. If you don't specify any categories the default behaviour is to 19 | activate the ``common`` category as well as a category with your machine's 20 | hostname (e.g. ``my-laptop``). 21 | 22 | When you run dotgit all the files in the filelist that are not part of the 23 | active categories will be ignored. You can run dotgit with two verbose flags 24 | ``-vv`` to see what categories are currently active. 25 | 26 | Flags 27 | ===== 28 | 29 | .. option:: -h, --help 30 | 31 | Display a help message 32 | 33 | .. option:: -v, --verbose 34 | 35 | Increase dotgit's verbosity level. Can be specified multiple times 36 | 37 | .. note:: 38 | 39 | It is a good idea to run dotgit with at least one ``-v`` flag since no 40 | output will be generated by default (unless there is an error). 41 | 42 | .. option:: --dry-run 43 | 44 | When specified dotgit won't make any changes to the filesystem. Useful when 45 | running with ``-v`` to see what dotgit would do if you run a command 46 | 47 | .. option:: --hard 48 | 49 | Activates "hard" mode where files are copied rather than symlinked. Useful 50 | if symlinking isn't an option or if you want the dotfiles to live on the 51 | machine independently of the dotgit repo. 52 | 53 | .. note:: 54 | 55 | If you want to use hard mode you need to specify it every time you run 56 | dotgit 57 | 58 | Actions 59 | ======= 60 | 61 | .. option:: init 62 | 63 | Initializes a new dotgit repository. Creates an empty filelist and also 64 | runs ``git init`` if the repo is not a valid git repository. You only need 65 | to run this once (when you set up a new dotgit repo). Running this multiple 66 | times has no effect. 67 | 68 | .. option:: update 69 | 70 | Updates the dotgit repository. Run this after you made changes to your 71 | filelist or if you want to add the changes to non-symlinked files to your 72 | repo (e.g. encrypted files). This will save your dotfiles from your home 73 | folder in your dotgit repo, and also set up the links/copies to your 74 | dotfiles repo as needed (runs a ``restore`` operation after updating). 75 | 76 | .. note:: 77 | 78 | When you run the ``update`` command dotgit will replace any files in the 79 | repo with those in your home folder. Make sure to run the ``restore`` 80 | command first on a new machine otherwise you might end up inadvertently 81 | replacing files in your repo. 82 | 83 | .. option:: restore 84 | 85 | Links or copies files from your dotgit repo to your home folder. Use this if 86 | you want to restore your dotfiles to a new machine. 87 | 88 | .. option:: clean 89 | 90 | Removes all the dotfiles managed by dotgit from your home folder (run first 91 | with the ``-v --dry-run`` flags to see what dotgit plans on doing). 92 | 93 | .. option:: diff 94 | 95 | Prints which changes have been made to your dotfiles repo since the last 96 | commit. 97 | 98 | .. option:: commit 99 | 100 | This will generate a git commit with all the current changes in the repo and 101 | will ask you if you want to push the commit to a remote (if one is 102 | configured). 103 | 104 | .. option:: passwd 105 | 106 | Allows you to change your encryption password. 107 | -------------------------------------------------------------------------------- /dotgit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kobus-v-schoor/dotgit/9d9fea55c39dd71fbc9e7aa73bc0feba58fe5e5c/dotgit/__init__.py -------------------------------------------------------------------------------- /dotgit/__main__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import logging 4 | import sys 5 | import os 6 | 7 | # add the directory which contains the dotgit module to the path. this will 8 | # only ever execute when running the __main__.py script directly since the 9 | # python package will use an entrypoint 10 | if __name__ == '__main__': 11 | import site 12 | mod = os.path.dirname(os.path.realpath(__file__)) 13 | site.addsitedir(os.path.dirname(mod)) 14 | 15 | from dotgit.args import Arguments 16 | from dotgit.enums import Actions 17 | from dotgit.checks import safety_checks 18 | from dotgit.flists import Filelist 19 | from dotgit.git import Git 20 | from dotgit.calc_ops import CalcOps 21 | from dotgit.plugins.plain import PlainPlugin 22 | from dotgit.plugins.encrypt import EncryptPlugin 23 | import dotgit.info as info 24 | 25 | 26 | def init_repo(repo_dir, flist): 27 | git = Git(repo_dir) 28 | if not os.path.isdir(os.path.join(repo_dir, '.git')): 29 | logging.info('creating git repo') 30 | git.init() 31 | else: 32 | logging.warning('existing git repo, not re-creating') 33 | 34 | changed = False 35 | 36 | if not os.path.isfile(flist): 37 | logging.info('creating empty filelist') 38 | open(flist, 'w').close() 39 | git.add(os.path.basename(flist)) 40 | changed = True 41 | else: 42 | logging.warning('existing filelist, not recreating') 43 | 44 | if changed: 45 | git.commit() 46 | 47 | 48 | def main(args=None, cwd=os.getcwd(), home=info.home): 49 | if args is None: 50 | args = sys.argv[1:] 51 | 52 | # parse cmd arguments 53 | args = Arguments(args) 54 | logging.basicConfig(format='%(message)s ', level=args.verbose_level) 55 | logging.debug(f'ran with arguments {args}') 56 | 57 | repo = cwd 58 | flist_fname = os.path.join(repo, 'filelist') 59 | 60 | # run safety checks 61 | if not safety_checks(repo, home, args.action == Actions.INIT): 62 | logging.error(f'safety checks failed for {os.getcwd()}, exiting') 63 | return 1 64 | 65 | # check for init 66 | if args.action == Actions.INIT: 67 | init_repo(repo, flist_fname) 68 | return 0 69 | 70 | # parse filelist 71 | filelist = Filelist(flist_fname) 72 | # generate manifest for later cleaning 73 | manifest = filelist.manifest() 74 | # activate categories on filelist 75 | try: 76 | filelist = filelist.activate(args.categories) 77 | except RuntimeError: 78 | return 1 79 | 80 | # set up git interface 81 | git = Git(repo) 82 | 83 | # set the dotfiles repo 84 | dotfiles = os.path.join(repo, 'dotfiles') 85 | logging.debug(f'dotfiles path is {dotfiles}') 86 | 87 | # init plugins 88 | plugins_data_dir = os.path.join(repo, '.plugins') 89 | plugins = { 90 | 'plain': PlainPlugin( 91 | data_dir=os.path.join(plugins_data_dir, 'plain'), 92 | repo_dir=os.path.join(dotfiles, 'plain'), 93 | hard=args.hard_mode), 94 | 'encrypt': EncryptPlugin( 95 | data_dir=os.path.join(plugins_data_dir, 'encrypt'), 96 | repo_dir=os.path.join(dotfiles, 'encrypt')) 97 | } 98 | 99 | plugin_dirs = {plugin: os.path.join(dotfiles, plugin) for plugin in 100 | plugins} 101 | 102 | if args.action in [Actions.UPDATE, Actions.RESTORE, Actions.CLEAN]: 103 | clean_ops = [] 104 | 105 | # calculate and apply file operations 106 | for plugin in plugins: 107 | # filter out filelist paths that use current plugin 108 | flist = {path: filelist[path]['categories'] for path in filelist if 109 | filelist[path]['plugin'] == plugin} 110 | if not flist: 111 | continue 112 | logging.debug(f'active filelist for plugin {plugin}: {flist}') 113 | 114 | plugin_dir = plugin_dirs[plugin] 115 | calc_ops = CalcOps(plugin_dir, home, plugins[plugin]) 116 | 117 | if args.action == Actions.UPDATE: 118 | calc_ops.update(flist).apply(args.dry_run) 119 | calc_ops.restore(flist).apply(args.dry_run) 120 | elif args.action == Actions.RESTORE: 121 | calc_ops.restore(flist).apply(args.dry_run) 122 | elif args.action == Actions.CLEAN: 123 | calc_ops.clean(flist).apply(args.dry_run) 124 | 125 | clean_ops.append(calc_ops.clean_repo(manifest[plugin])) 126 | plugins[plugin].clean_data(manifest[plugin]) 127 | 128 | # execute cleaning ops after everything else 129 | for clean_op in clean_ops: 130 | clean_op.apply(args.dry_run) 131 | 132 | elif args.action in [Actions.DIFF, Actions.COMMIT]: 133 | # calculate and apply git operations 134 | if args.action == Actions.DIFF: 135 | print('\n'.join(git.diff(ignore=['.plugins/']))) 136 | 137 | for plugin in plugins: 138 | calc_ops = CalcOps(plugin_dirs[plugin], home, plugins[plugin]) 139 | diff = calc_ops.diff(args.categories) 140 | 141 | if diff: 142 | print(f'\n{plugin}-plugin updates not yet in repo:') 143 | print('\n'.join(diff)) 144 | 145 | elif args.action == Actions.COMMIT: 146 | if not git.has_changes(): 147 | logging.warning('no changes detected in repo, not creating ' 148 | 'commit') 149 | return 0 150 | git.add() 151 | msg = git.gen_commit_message(ignore=['.plugins/']) 152 | git.commit(msg) 153 | 154 | if git.has_remote(): 155 | ans = input('remote for repo detected, push to remote? [Yn] ') 156 | ans = ans if ans else 'y' 157 | if ans.lower() == 'y': 158 | git.push() 159 | logging.info('successfully pushed to git remote') 160 | 161 | elif args.action == Actions.PASSWD: 162 | logging.debug('attempting to change encryption password') 163 | repo = os.path.join(dotfiles, 'encrypt') 164 | if os.path.exists(repo): 165 | plugins['encrypt'].init_password() 166 | plugins['encrypt'].change_password(repo=repo) 167 | else: 168 | plugins['encrypt'].change_password() 169 | 170 | return 0 171 | 172 | 173 | if __name__ == '__main__': 174 | sys.exit(main()) 175 | -------------------------------------------------------------------------------- /dotgit/args.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | 4 | from dotgit.enums import Actions 5 | import dotgit.info as info 6 | 7 | HELP = { 8 | 'verbose': 'increase verbosity level', 9 | 'dry-run': 'do not actually execute any file operations', 10 | 'hard-mode': 'copy files instead of symlinking them', 11 | 'action': 'action to take on active categories', 12 | 'category': 'categories to activate. (default: %(default)s)' 13 | } 14 | 15 | EPILOG = 'See full the documentation at https://dotgit.readthedocs.io/' 16 | 17 | 18 | class Arguments: 19 | def __init__(self, args=None): 20 | # construct parser 21 | formatter = argparse.RawDescriptionHelpFormatter 22 | parser = argparse.ArgumentParser(epilog=EPILOG, 23 | formatter_class=formatter) 24 | 25 | # add parser options 26 | parser.add_argument('--version', action='version', 27 | version=f'dotgit {info.__version__}') 28 | parser.add_argument('--verbose', '-v', action='count', default=0, 29 | help=HELP['verbose']) 30 | parser.add_argument('--dry-run', action='store_true', 31 | help=HELP['dry-run']) 32 | parser.add_argument('--hard', action='store_true', 33 | help=HELP['hard-mode']) 34 | 35 | parser.add_argument('action', choices=[a.value for a in Actions], 36 | help=HELP['action']) 37 | parser.add_argument('category', nargs='*', 38 | default=['common', info.hostname], 39 | help=HELP['category']) 40 | 41 | # parse args 42 | args = parser.parse_args(args) 43 | 44 | # extract settings 45 | if args.verbose: 46 | args.verbose = min(args.verbose, 2) 47 | self.verbose_level = (logging.INFO if args.verbose < 2 else 48 | logging.DEBUG) 49 | else: 50 | self.verbose_level = logging.WARNING 51 | 52 | self.dry_run = args.dry_run 53 | self.hard_mode = args.hard 54 | self.action = Actions(args.action) 55 | self.categories = args.category 56 | 57 | def __str__(self): 58 | return str(vars(self)) 59 | -------------------------------------------------------------------------------- /dotgit/calc_ops.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from dotgit.file_ops import FileOps 5 | 6 | 7 | class CalcOps: 8 | def __init__(self, repo, restore_path, plugin): 9 | self.repo = str(repo) 10 | self.restore_path = str(restore_path) 11 | self.plugin = plugin 12 | 13 | def update(self, files): 14 | fops = FileOps(self.repo) 15 | 16 | for path in files: 17 | categories = files[path] 18 | 19 | master = min(categories) 20 | slaves = [c for c in categories if c != master] 21 | 22 | # checks if a candidate exists and also checks if the candidate is 23 | # a link so that its resolved path can be used 24 | original_path = {} 25 | 26 | def check_cand(cand): 27 | cand = os.path.join(cand, path) 28 | if os.path.isfile(cand): 29 | if os.path.islink(cand): 30 | old = cand 31 | cand = os.path.realpath(cand) 32 | original_path[cand] = old 33 | return [cand] 34 | return [] 35 | 36 | candidates = [] 37 | candidates += check_cand(self.restore_path) 38 | 39 | # candidate not found in restore path, so check elsewhere 40 | if not candidates: 41 | for cand in [os.path.join(self.repo, c) for c in categories]: 42 | candidates += check_cand(cand) 43 | else: 44 | logging.debug(f'"{path}" found in restore path, so overriding ' 45 | 'any other candidates') 46 | 47 | if not candidates: 48 | logging.warning(f'unable to find any candidates for "{path}"') 49 | continue 50 | 51 | candidates = list(set(candidates)) 52 | if len(candidates) > 1: 53 | print(f'multiple candidates found for {path}:\n') 54 | 55 | for i, cand in enumerate(candidates): 56 | print(f'[{i}] {cand}') 57 | print('[-1] cancel') 58 | 59 | while True: 60 | try: 61 | choice = int(input('please select the version you ' 62 | 'would like to use ' 63 | f'[0-{len(candidates)-1}]: ')) 64 | choice = candidates[choice] 65 | except (ValueError, EOFError): 66 | print('invalid choice entered, please try again') 67 | continue 68 | break 69 | source = choice 70 | 71 | # if one of the candidates is not in the repo and it is not the 72 | # source it should be deleted manually since it will not be 73 | # deleted in the slave linking below, as the other candidates 74 | # would be 75 | restore_path = os.path.join(self.restore_path, path) 76 | if restore_path in candidates and source != restore_path: 77 | fops.remove(restore_path) 78 | 79 | else: 80 | source = candidates.pop() 81 | 82 | master = os.path.join(self.repo, master, path) 83 | slaves = [os.path.join(self.repo, s, path) for s in slaves] 84 | 85 | if source != master and not self.plugin.samefile(master, source): 86 | if os.path.exists(master): 87 | fops.remove(master) 88 | # check if source is in repo, if it is not apply the plugin 89 | if source.startswith(self.repo + os.sep): 90 | # if the source is one of the slaves, move the source 91 | # otherwise just copy it because it might have changed into 92 | # a seperate category - cleanup will remove it if needed 93 | if source in slaves: 94 | fops.move(source, master) 95 | else: 96 | fops.copy(source, master) 97 | else: 98 | fops.plugin(self.plugin.apply, source, master) 99 | if source in original_path: 100 | fops.remove(original_path[source]) 101 | else: 102 | fops.remove(source) 103 | 104 | for slave in slaves: 105 | if slave != source: 106 | if os.path.isfile(slave) or os.path.islink(slave): 107 | if os.path.realpath(slave) != master: 108 | fops.remove(slave) 109 | else: 110 | # already linked to master so just ignore 111 | continue 112 | fops.link(master, slave) 113 | 114 | return fops 115 | 116 | def restore(self, files): 117 | fops = FileOps(self.repo) 118 | 119 | for path in files: 120 | categories = files[path] 121 | master = min(categories) 122 | source = os.path.join(self.repo, master, path) 123 | 124 | if not os.path.exists(source): 125 | logging.debug(f'{source} not found in repo') 126 | logging.warning(f'unable to find "{path}" in repo, skipping') 127 | continue 128 | 129 | dest = os.path.join(self.restore_path, path) 130 | 131 | if os.path.exists(dest): 132 | if self.plugin.samefile(source, dest): 133 | logging.debug(f'{dest} is the same file as in the repo, ' 134 | 'skipping') 135 | continue 136 | 137 | # check if the dest is already a symlink to the repo, if it is 138 | # just remove it without asking 139 | if os.path.realpath(dest).startswith(self.repo): 140 | logging.info(f'{dest} already linked to repo, replacing ' 141 | 'with new file') 142 | fops.remove(dest) 143 | else: 144 | a = input(f'{dest} already exists, replace? [Yn] ') 145 | a = 'y' if not a else a 146 | if a.lower() == 'y': 147 | fops.remove(dest) 148 | else: 149 | continue 150 | # check if the destination is a dangling symlink, if it is just 151 | # remove it 152 | elif os.path.islink(dest): 153 | fops.remove(dest) 154 | 155 | fops.plugin(self.plugin.remove, source, dest) 156 | 157 | return fops 158 | 159 | # removes links from restore path that point to the repo 160 | def clean(self, files): 161 | fops = FileOps(self.repo) 162 | 163 | for path in files: 164 | categories = files[path] 165 | master = min(categories) 166 | repo_path = os.path.join(self.repo, master, path) 167 | 168 | restore_path = os.path.join(self.restore_path, path) 169 | 170 | if os.path.exists(repo_path) and os.path.exists(restore_path): 171 | if self.plugin.samefile(repo_path, restore_path): 172 | fops.remove(restore_path) 173 | 174 | return fops 175 | 176 | # will go through the repo and search for files that should no longer be 177 | # there. accepts a list of filenames that are allowed 178 | def clean_repo(self, filenames): 179 | fops = FileOps(self.repo) 180 | 181 | if not os.path.isdir(self.repo): 182 | return fops 183 | 184 | for category in os.listdir(self.repo): 185 | category_path = os.path.join(self.repo, category) 186 | 187 | # remove empty category folders 188 | if not os.listdir(category_path): 189 | logging.info(f'{category} is empty, removing') 190 | fops.remove(category) 191 | continue 192 | 193 | for root, dirs, files in os.walk(category_path): 194 | # remove empty directories 195 | for dname in dirs: 196 | dname = os.path.join(root, dname) 197 | if not os.listdir(dname): 198 | dname = os.path.relpath(dname, self.repo) 199 | logging.info(f'{dname} is empty, removing') 200 | fops.remove(dname) 201 | 202 | # remove files that are not in the manifest 203 | for fname in files: 204 | fname = os.path.relpath(os.path.join(root, fname), 205 | self.repo) 206 | if fname not in filenames: 207 | logging.info(f'{fname} is not in the manifest, ' 208 | 'removing') 209 | fops.remove(fname) 210 | 211 | return fops 212 | 213 | # goes through the filelist and finds files that have modifications that 214 | # are not yet in the repo e.g. changes to encrypted files. This should not 215 | # be used for any calculations, only for informational purposes 216 | def diff(self, categories): 217 | diffs = [] 218 | for category in categories: 219 | category_path = os.path.join(self.repo, category) 220 | 221 | for root, dirs, files in os.walk(category_path): 222 | for fname in files: 223 | fname = os.path.join(root, fname) 224 | fname = os.path.relpath(fname, category_path) 225 | 226 | restore_file = os.path.join(self.restore_path, fname) 227 | category_file = os.path.join(category_path, fname) 228 | 229 | if not os.path.exists(restore_file): 230 | continue 231 | 232 | logging.debug(f'checking diff samefile for {restore_file}') 233 | if not self.plugin.samefile(category_file, restore_file): 234 | diffs.append(f'modified {restore_file}') 235 | 236 | return diffs 237 | -------------------------------------------------------------------------------- /dotgit/checks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import subprocess 4 | 5 | 6 | def safety_checks(dir_name, home, init): 7 | # check that we're not in the user's home folder 8 | if dir_name == home: 9 | logging.error('dotgit should not be run inside home folder') 10 | return False 11 | 12 | try: 13 | subprocess.run(['git', '--version'], check=True, 14 | stdout=subprocess.PIPE) 15 | except FileNotFoundError: 16 | logging.error('"git" command not found in path, needed for proper ' 17 | 'dotgit operation') 18 | return False 19 | 20 | if init: 21 | return True 22 | 23 | if os.path.isfile(os.path.join(dir_name, 'cryptlist')): 24 | logging.error('this appears to be an old dotgit repo, please check ' 25 | 'https://github.com/kobus-v-schoor/dotgit for ' 26 | 'instructions on how to migrate your repo to the new ' 27 | 'version of dotgit or use the old version of dotgit by ' 28 | 'rather running "dotgit.sh"') 29 | return False 30 | 31 | if not os.path.isdir(os.path.join(dir_name, '.git')): 32 | logging.error('this does not appear to be a git repo, make sure to ' 33 | 'init the repo before running dotgit in this folder') 34 | return False 35 | 36 | for flist in ['filelist']: 37 | if not os.path.isfile(os.path.join(dir_name, flist)): 38 | logging.error(f'unable to locate {flist} in repo') 39 | return False 40 | 41 | return True 42 | -------------------------------------------------------------------------------- /dotgit/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class Actions(enum.Enum): 5 | INIT = 'init' 6 | 7 | UPDATE = 'update' 8 | RESTORE = 'restore' 9 | CLEAN = 'clean' 10 | 11 | DIFF = 'diff' 12 | COMMIT = 'commit' 13 | 14 | PASSWD = 'passwd' 15 | -------------------------------------------------------------------------------- /dotgit/file_ops.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import enum 4 | import shutil 5 | import inspect 6 | 7 | 8 | class Op(enum.Enum): 9 | LINK = enum.auto() 10 | COPY = enum.auto() 11 | MOVE = enum.auto() 12 | REMOVE = enum.auto() 13 | MKDIR = enum.auto() 14 | 15 | 16 | class FileOps: 17 | def __init__(self, wd): 18 | self.wd = wd 19 | self.ops = [] 20 | 21 | def clear(self): 22 | self.ops = [] 23 | 24 | def check_path(self, path): 25 | return path if os.path.isabs(path) else os.path.join(self.wd, path) 26 | 27 | def check_dest_dir(self, path): 28 | dirname = os.path.dirname(path) 29 | if not os.path.isdir(self.check_path(dirname)): 30 | self.mkdir(dirname) 31 | 32 | def mkdir(self, path): 33 | logging.debug(f'adding mkdir op for {path}') 34 | self.ops.append((Op.MKDIR, path)) 35 | 36 | def copy(self, source, dest): 37 | logging.debug(f'adding cp op for {source} -> {dest}') 38 | self.check_dest_dir(dest) 39 | self.ops.append((Op.COPY, (source, dest))) 40 | 41 | def move(self, source, dest): 42 | logging.debug(f'adding mv op for {source} -> {dest}') 43 | self.check_dest_dir(dest) 44 | self.ops.append((Op.MOVE, (source, dest))) 45 | 46 | def link(self, source, dest): 47 | logging.debug(f'adding ln op for {source} <- {dest}') 48 | self.check_dest_dir(dest) 49 | self.ops.append((Op.LINK, (source, dest))) 50 | 51 | def remove(self, path): 52 | logging.debug(f'adding rm op for {path}') 53 | self.ops.append((Op.REMOVE, path)) 54 | 55 | def plugin(self, plugin, source, dest): 56 | logging.debug(f'adding plugin op ({plugin.__qualname__}) for {source} ' 57 | f'-> {dest}') 58 | self.check_dest_dir(dest) 59 | self.ops.append((plugin, (source, dest))) 60 | 61 | def apply(self, dry_run=False): 62 | for op in self.ops: 63 | op, path = op 64 | 65 | if type(path) is tuple: 66 | src, dest = path 67 | src, dest = self.check_path(src), self.check_path(dest) 68 | logging.info(self.str_op(op, (src, dest))) 69 | else: 70 | path = self.check_path(path) 71 | logging.info(self.str_op(op, path)) 72 | 73 | if dry_run: 74 | continue 75 | 76 | if op == Op.LINK: 77 | src = os.path.relpath(src, os.path.join(self.wd, 78 | os.path.dirname(dest))) 79 | os.symlink(src, dest) 80 | elif op == Op.COPY: 81 | shutil.copyfile(src, dest) 82 | elif op == Op.MOVE: 83 | os.rename(src, dest) 84 | elif op == Op.REMOVE: 85 | if os.path.isdir(path): 86 | shutil.rmtree(path) 87 | else: 88 | os.remove(path) 89 | elif op == Op.MKDIR: 90 | if not os.path.isdir(path): 91 | os.makedirs(path) 92 | elif callable(op): 93 | op(src, dest) 94 | 95 | self.clear() 96 | 97 | def append(self, other): 98 | self.ops += other.ops 99 | return self 100 | 101 | def str_op(self, op, path): 102 | def strip_wd(p): 103 | p = str(p) 104 | wd = str(self.wd) 105 | return p[len(wd) + 1:] if p.startswith(wd) else p 106 | 107 | if type(op) is Op: 108 | op = op.name 109 | else: 110 | op = dict(inspect.getmembers(op))['__self__'].strify(op) 111 | 112 | if type(path) is tuple: 113 | path = [strip_wd(p) for p in path] 114 | return f'{op} "{path[0]}" -> "{path[1]}"' 115 | else: 116 | return f'{op} "{strip_wd(path)}"' 117 | 118 | def __str__(self): 119 | return '\n'.join(self.str_op(*op) for op in self.ops) 120 | 121 | def __repr__(self): 122 | return str(self.ops) 123 | -------------------------------------------------------------------------------- /dotgit/flists.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | import dotgit.info as info 6 | 7 | 8 | class Filelist: 9 | def __init__(self, fname): 10 | self.groups = {} 11 | self.files = {} 12 | 13 | logging.debug(f'parsing filelist in {fname}') 14 | 15 | with open(fname, 'r') as f: 16 | for line in f.readlines(): 17 | line = line.strip() 18 | 19 | if not line or line.startswith('#'): 20 | continue 21 | 22 | # group 23 | if '=' in line: 24 | group, categories = line.split('=') 25 | categories = categories.split(',') 26 | if group == info.hostname: 27 | categories.append(info.hostname) 28 | self.groups[group] = categories 29 | # file 30 | else: 31 | split = re.split('[:|]', line) 32 | 33 | path, categories, plugin = split[0], ['common'], 'plain' 34 | if len(split) >= 2: 35 | if ':' in line: 36 | categories = split[1].split(',') 37 | else: 38 | plugin = split[1] 39 | if len(split) >= 3: 40 | plugin = split[2] 41 | 42 | if path not in self.files: 43 | self.files[path] = [] 44 | self.files[path].append({ 45 | 'categories': categories, 46 | 'plugin': plugin 47 | }) 48 | 49 | def activate(self, categories): 50 | # expand groups 51 | categories = [self.groups.get(c, [c]) for c in categories] 52 | # flatten category list 53 | categories = [c for cat in categories for c in cat] 54 | 55 | files = {} 56 | for path in self.files: 57 | for group in self.files[path]: 58 | cat_list = group['categories'] 59 | if set(categories) & set(cat_list): 60 | if path in files: 61 | logging.error('multiple category lists active for ' 62 | f'{path}: {files[path]["categories"]} ' 63 | f'and {cat_list}') 64 | raise RuntimeError 65 | else: 66 | files[path] = group 67 | 68 | return files 69 | 70 | # generates a list of all the filenames in each plugin for later use when 71 | # cleaning the repo 72 | def manifest(self): 73 | manifest = {} 74 | 75 | for path in self.files: 76 | for instance in self.files[path]: 77 | plugin = instance['plugin'] 78 | for category in instance['categories']: 79 | if category in self.groups: 80 | categories = self.groups[category] 81 | else: 82 | categories = [category] 83 | 84 | if plugin not in manifest: 85 | manifest[plugin] = [] 86 | 87 | for category in categories: 88 | manifest[plugin].append(os.path.join(category, path)) 89 | 90 | return manifest 91 | -------------------------------------------------------------------------------- /dotgit/git.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import shlex 4 | import logging 5 | import enum 6 | 7 | 8 | class FileState(enum.Enum): 9 | MODIFIED = 'M' 10 | ADDED = 'A' 11 | DELETED = 'D' 12 | RENAMED = 'R' 13 | COPIED = 'C' 14 | UPDATED = 'U' 15 | UNTRACKED = '?' 16 | 17 | 18 | class Git: 19 | def __init__(self, repo_dir): 20 | if not os.path.isdir(repo_dir): 21 | raise FileNotFoundError 22 | 23 | self.repo_dir = repo_dir 24 | 25 | def run(self, cmd): 26 | if not type(cmd) is list: 27 | cmd = shlex.split(cmd) 28 | logging.info(f'running git command {cmd}') 29 | try: 30 | proc = subprocess.run(cmd, cwd=self.repo_dir, 31 | stdout=subprocess.PIPE, check=True) 32 | except subprocess.CalledProcessError as e: 33 | logging.error(e.stdout.decode()) 34 | logging.error(f'git command {cmd} failed with exit code ' 35 | f'{e.returncode}\n') 36 | raise 37 | logging.debug(f'git command {cmd} succeeded') 38 | return proc.stdout.decode() 39 | 40 | def init(self): 41 | self.run('git init') 42 | 43 | def reset(self, fname=None): 44 | self.run('git reset' if fname is None else f'git reset {fname}') 45 | 46 | def add(self, fname=None): 47 | self.run('git add --all' if fname is None else f'git add {fname}') 48 | 49 | def commit(self, message=None): 50 | if message is None: 51 | message = self.gen_commit_message() 52 | return self.run(['git', 'commit', '-m', message]) 53 | 54 | def status(self, staged=True): 55 | out = self.run('git status --porcelain').strip() 56 | status = [] 57 | for line in out.split('\n'): 58 | state, path = line[:2], line[3:] 59 | stage, work = state 60 | status.append((FileState(stage if staged else work), path)) 61 | return sorted(status, key=lambda s: s[1]) 62 | 63 | def has_changes(self): 64 | return bool(self.run('git status -s --porcelain').strip()) 65 | 66 | def gen_commit_message(self, ignore=[]): 67 | mods = [] 68 | for stat in self.status(): 69 | state, path = stat 70 | # skip all untracked files since they will not be committed 71 | if state == FileState.UNTRACKED: 72 | continue 73 | if any((path.startswith(p) for p in ignore)): 74 | logging.debug(f'ignoring {path} from commit message') 75 | continue 76 | mods.append(f'{state.name.lower()} {path}') 77 | return ', '.join(mods).capitalize() 78 | 79 | def commits(self): 80 | return self.run('git log -1 --pretty=%s').strip().split('\n') 81 | 82 | def last_commit(self): 83 | return self.commits()[-1] 84 | 85 | def has_remote(self): 86 | return bool(self.run('git remote').strip()) 87 | 88 | def push(self): 89 | self.run('git push') 90 | 91 | def diff(self, ignore=[]): 92 | if not self.has_changes(): 93 | return ['no changes'] 94 | 95 | self.add() 96 | status = self.status() 97 | self.reset() 98 | 99 | diff = [] 100 | 101 | for path in status: 102 | # ignore the paths specified in ignore 103 | if any((path[1].startswith(i) for i in ignore)): 104 | continue 105 | diff.append(f'{path[0].name.lower()} {path[1]}') 106 | 107 | return diff 108 | -------------------------------------------------------------------------------- /dotgit/info.py: -------------------------------------------------------------------------------- 1 | from os.path import expanduser 2 | import socket 3 | 4 | __version__ = '2.2.9' 5 | __author__ = 'Kobus van Schoor' 6 | __author_email__ = 'v.schoor.kobus@gmail.com' 7 | __url__ = 'https://github.com/kobus-v-schoor/dotgit' 8 | __license__ = 'GNU General Public License v2 (GPLv2)' 9 | 10 | home = expanduser('~') 11 | hostname = socket.gethostname() 12 | -------------------------------------------------------------------------------- /dotgit/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Plugin: 5 | def __init__(self, data_dir, repo_dir=None): 6 | self.data_dir = data_dir 7 | self.repo_dir = '/' if repo_dir is None else repo_dir 8 | 9 | if not os.path.isdir(self.data_dir): 10 | os.makedirs(self.data_dir) 11 | 12 | self.setup_data() 13 | 14 | # does plugin-specific setting up of data located in the data_dir 15 | def setup_data(self): 16 | pass 17 | 18 | # cleans up plugin's data by removing entries that is no longer in the 19 | # given manifest 20 | def clean_data(self, manifest): 21 | pass 22 | 23 | # takes a source (outside the repo) and applies its operation and store the 24 | # resulting file in dest (inside the repo). This operation should not 25 | # remove the source file 26 | def apply(self, source, dest): 27 | pass 28 | 29 | # takes a source (inside the repo) and removes its operation and stores the 30 | # result in dest (outside the repo) 31 | def remove(self, source, dest): 32 | pass 33 | 34 | # takes a path to a repo_file and an ext_file and compares them, should 35 | # return true if they are the same file 36 | def samefile(self, repo_file, ext_file): 37 | pass 38 | 39 | # takes a callable (one of the plugin's ops) and returns a string 40 | # describing the op 41 | def strify(self, op): 42 | pass 43 | 44 | # takes a path inside the repo and strips the repo dir as a prefix 45 | def strip_repo(self, path): 46 | if os.path.isabs(path): 47 | return os.path.relpath(path, self.repo_dir) 48 | return path 49 | -------------------------------------------------------------------------------- /dotgit/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kobus-v-schoor/dotgit/9d9fea55c39dd71fbc9e7aa73bc0feba58fe5e5c/dotgit/plugins/__init__.py -------------------------------------------------------------------------------- /dotgit/plugins/encrypt.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | import logging 4 | import json 5 | import getpass 6 | import hashlib 7 | import os 8 | import tempfile 9 | 10 | from dotgit.plugin import Plugin 11 | 12 | 13 | class GPG: 14 | def __init__(self, password): 15 | self.password = password 16 | 17 | def run(self, cmd): 18 | if not type(cmd) is list: 19 | cmd = shlex.split(cmd) 20 | 21 | # these are needed to read the password from stdin and to not ask 22 | # questions 23 | pre = ['--passphrase-fd', '0', '--pinentry-mode', 'loopback', 24 | '--batch', '--yes'] 25 | # insert pre into the gpg command string 26 | cmd = cmd[:1] + pre + cmd[1:] 27 | 28 | logging.debug(f'running gpg command {cmd}') 29 | 30 | try: 31 | proc = subprocess.run(cmd, input=self.password.encode(), 32 | stdout=subprocess.PIPE, 33 | stderr=subprocess.PIPE, check=True) 34 | except subprocess.CalledProcessError as e: 35 | logging.error(e.stderr.decode()) 36 | logging.error(f'gpg command {cmd} failed with exit code ' 37 | f'{e.returncode}\n') 38 | raise 39 | 40 | logging.debug(f'gpg command {cmd} succeeded') 41 | return proc.stdout.decode() 42 | 43 | def encrypt(self, input_file, output_file): 44 | self.run(f'gpg --armor --output {shlex.quote(output_file)} ' 45 | f'--symmetric {shlex.quote(input_file)}') 46 | 47 | def decrypt(self, input_file, output_file): 48 | self.run(f'gpg --output {shlex.quote(output_file)} ' 49 | f'--decrypt {shlex.quote(input_file)}') 50 | 51 | 52 | # calculates the sha256 hash of the file at fpath 53 | def hash_file(path): 54 | h = hashlib.sha256() 55 | 56 | with open(path, 'rb') as f: 57 | while True: 58 | chunk = f.read(h.block_size) 59 | if not chunk: 60 | break 61 | h.update(chunk) 62 | 63 | return h.hexdigest() 64 | 65 | 66 | # hash password using suitable key-stretching algorithm 67 | # salt needs to be >16 bits from a suitable cryptographically secure random 68 | # source, but can be stored in plaintext 69 | def key_stretch(password, salt): 70 | if type(password) is not bytes: 71 | password = password.encode() 72 | if type(salt) is not bytes: 73 | salt = bytes.fromhex(salt) 74 | key = hashlib.pbkdf2_hmac(hash_name='sha256', password=password, salt=salt, 75 | iterations=100000) 76 | return key.hex() 77 | 78 | 79 | class EncryptPlugin(Plugin): 80 | def __init__(self, data_dir, *args, **kwargs): 81 | self.gpg = None 82 | self.hashes_path = os.path.join(data_dir, 'hashes') 83 | self.modes_path = os.path.join(data_dir, 'modes') 84 | self.pword_path = os.path.join(data_dir, 'passwd') 85 | super().__init__(*args, data_dir=data_dir, **kwargs) 86 | 87 | # reads the stored hashes 88 | def setup_data(self): 89 | if os.path.exists(self.hashes_path): 90 | with open(self.hashes_path, 'r') as f: 91 | self.hashes = json.load(f) 92 | else: 93 | self.hashes = {} 94 | 95 | if os.path.exists(self.modes_path): 96 | with open(self.modes_path, 'r') as f: 97 | self.modes = json.load(f) 98 | else: 99 | self.modes = {} 100 | 101 | # removes file entries in modes and hashes that are no longer in the 102 | # manifest 103 | def clean_data(self, manifest): 104 | for data in [self.hashes, self.modes]: 105 | diff = set(data) - set(manifest) 106 | for key in diff: 107 | data.pop(key) 108 | self.save_data() 109 | 110 | # saves the current hashes and modes to the data dir 111 | def save_data(self): 112 | with open(self.hashes_path, 'w') as f: 113 | json.dump(self.hashes, f) 114 | with open(self.modes_path, 'w') as f: 115 | json.dump(self.modes, f) 116 | 117 | # sets the password in the plugin's data dir. do not use directly, use 118 | # change_password instead 119 | def save_password(self, password): 120 | # get salt from crypto-safe random source 121 | salt = os.urandom(32) 122 | # calculate password hash 123 | key = key_stretch(password.encode(), salt) 124 | 125 | # save salt and hash 126 | with open(self.pword_path, 'w') as f: 127 | d = {'pword': key, 'salt': salt.hex()} 128 | json.dump(d, f) 129 | 130 | # takes a password and checks if the correct password was entered 131 | def verify_password(self, password): 132 | with open(self.pword_path, 'r') as f: 133 | d = json.load(f) 134 | return key_stretch(password, d['salt']) == d['pword'] 135 | 136 | # asks the user for a new password and re-encrypts all the files with the 137 | # new password. if repo is None no attempt is made to re-encrypt files 138 | def change_password(self, repo=None): 139 | while True: 140 | p1 = getpass.getpass(prompt='Enter new password: ') 141 | p2 = getpass.getpass(prompt='Re-enter new password: ') 142 | 143 | if p1 != p2: 144 | print('Entered passwords do not match, please try again') 145 | else: 146 | break 147 | 148 | new_pword = p1 149 | new_gpg = GPG(new_pword) 150 | 151 | if repo is not None: 152 | self.init_password() 153 | 154 | for root, dirs, files in os.walk(repo): 155 | for fname in files: 156 | fname = os.path.join(root, fname) 157 | logging.info(f'changing passphrase for ' 158 | f'{os.path.relpath(fname, repo)}') 159 | 160 | # make a secure temporary file 161 | fs, sfname = tempfile.mkstemp() 162 | # close the file-handle since we won't be using it (just 163 | # there for gpg to write to) 164 | os.close(fs) 165 | 166 | try: 167 | # decrypt with old passphrase and re-encrypt with new 168 | # passphrase 169 | self.gpg.decrypt(fname, sfname) 170 | new_gpg.encrypt(sfname, fname) 171 | except: # noqa: E722 172 | raise 173 | finally: 174 | os.remove(sfname) 175 | 176 | self.gpg = new_gpg 177 | self.save_password(new_pword) 178 | return new_pword 179 | 180 | # gets the password from the user if needed 181 | def init_password(self): 182 | if self.gpg is not None: 183 | return 184 | 185 | if not os.path.exists(self.pword_path): 186 | print('No encryption password was found for this repo. To ' 187 | 'continue please set an encryption password\n') 188 | password = self.change_password() 189 | else: 190 | while True: 191 | password = getpass.getpass(prompt='Encryption password: ') 192 | if self.verify_password(password): 193 | break 194 | print('Incorrect password entered, please try again') 195 | 196 | self.gpg = GPG(password) 197 | 198 | # encrypts a file from outside the repo and stores it inside the repo 199 | def apply(self, source, dest): 200 | self.init_password() 201 | self.gpg.encrypt(source, dest) 202 | 203 | # calculate and store file hash 204 | self.hashes[self.strip_repo(dest)] = hash_file(source) 205 | # store file mode data (metadata) 206 | self.modes[self.strip_repo(dest)] = os.stat(source).st_mode & 0o777 207 | 208 | self.save_data() 209 | 210 | # decrypts source and saves it in dest 211 | def remove(self, source, dest): 212 | self.init_password() 213 | self.gpg.decrypt(source, dest) 214 | os.chmod(dest, self.modes[self.strip_repo(source)]) 215 | 216 | # compares the ext_file to repo_file and returns true if they are the same. 217 | # does this by looking at the repo_file's hash and calculating the hash of 218 | # the ext_file 219 | def samefile(self, repo_file, ext_file): 220 | ext_hash = hash_file(ext_file) 221 | repo_file = self.strip_repo(repo_file) 222 | return self.hashes.get(repo_file, None) == ext_hash 223 | 224 | def strify(self, op): 225 | if op == self.apply: 226 | return "ENCRYPT" 227 | elif op == self.remove: 228 | return "DECRYPT" 229 | else: 230 | return "" 231 | -------------------------------------------------------------------------------- /dotgit/plugins/plain.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import filecmp 4 | 5 | from dotgit.plugin import Plugin 6 | 7 | 8 | class PlainPlugin(Plugin): 9 | def __init__(self, *args, **kwargs): 10 | self.hard = kwargs.pop('hard', False) 11 | super().__init__(*args, **kwargs) 12 | 13 | def setup_data(self): 14 | pass 15 | 16 | # copies file from outside the repo to the repo 17 | def apply(self, source, dest): 18 | shutil.copy2(source, dest) 19 | 20 | # if not in hard mode, creates a symlink in dest (outside the repo) that 21 | # points to source (inside the repo) 22 | # if in hard mode, copies the file from the repo to the dest. 23 | def remove(self, source, dest): 24 | if self.hard: 25 | shutil.copy2(source, dest) 26 | else: 27 | os.symlink(source, dest) 28 | 29 | # if not in hard mode, checks if symlink points to file in repo 30 | # if in hard mode, a bit-by-bit comparison is made to compare the files 31 | def samefile(self, repo_file, ext_file): 32 | if self.hard: 33 | if os.path.islink(ext_file): 34 | return False 35 | if not os.path.exists(repo_file): 36 | return False 37 | return filecmp.cmp(repo_file, ext_file, shallow=False) 38 | else: 39 | # not using os.samefile since it resolves repo_file as well which 40 | # is not what we want 41 | return os.path.realpath(ext_file) == os.path.abspath(repo_file) 42 | 43 | def strify(self, op): 44 | if op == self.apply: 45 | return "COPY" 46 | elif op == self.remove: 47 | return "COPY" if self.hard else "LINK" 48 | return "" 49 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test lint package clean docs 2 | 3 | test: 4 | pytest-3 -v 5 | 6 | lint: 7 | python3 -m flake8 dotgit --count --statistics --show-source 8 | 9 | package: 10 | python3 setup.py sdist bdist_wheel 11 | 12 | clean: 13 | rm -rf build dist dotgit.egg-info 14 | 15 | docs: 16 | sphinx-build -M html docs docs/_build 17 | -------------------------------------------------------------------------------- /old/README.md: -------------------------------------------------------------------------------- 1 | # dotgit 2 | ## A comprehensive and versatile dotfiles manager 3 | 4 | Using dotgit will allow you to effortlessly store all your dotfiles in a single 5 | git repository. dotgit doesn't only do storage - it also manages your dotfiles 6 | between multiple computers and devices. 7 | 8 | ## Project goals 9 | * Make it possible to store different versions of the same file in a single 10 | repository, but also to 11 | * Make it possible to share the same file between more than one host/category 12 | * Make use of an intuitive filelist 13 | * Use (easy) one-liners to set up repository on new host 14 | * Categorise files 15 | * Make usage with git convenient and easy, but don't impair git's power 16 | * Keep ALL the dotfiles in one, single repository 17 | * Support for directories 18 | * Support for encryption 19 | 20 | ## Why use dotgit? 21 | * If you're uncomfortable with git, let dotgit work with git for you. If you 22 | prefer to work with git yourself you can easily do that - a dotgit repository 23 | is just a normal git repository, no frills 24 | * Equally good support for both symlinks and copies 25 | * No dependencies, just a bash script 26 | * Intuitive filelist - easily create a complex repository storing all your 27 | different configurations 28 | * Easily work with only a group of files in your repository (categories) 29 | * Straightforward file-hierarchy 30 | * Support for directories 31 | * Secure implementation of GnuPG AES encryption 32 | 33 | ## What makes dotgit different? 34 | While dotgit is one of many dotfile managers, there are some key differences 35 | when compared with others: 36 | * [yadm](https://github.com/TheLocehiliosan/yadm) - dotgit's way of separating 37 | files for different hosts is a lot easier and doesn't involve renaming the 38 | files. 39 | * [vcsh](https://github.com/RichiH/vcsh) - While vcsh is very powerful, dotgit 40 | is a lot easier to set up, use and maintain over multiple machines (the only 41 | time you run a dotgit command is when you changed the filelist). vcsh also 42 | uses multiple repositories, something I personally wanted to avoid when I 43 | tried versioning my dotfiles. 44 | * [homeshick](https://github.com/andsens/homeshick) - dotgit also allows 45 | multiple configurations (categories), but still keeps them in a single 46 | repository. 47 | 48 | All the above tools are great, and I encourage you to check them out. dotgit 49 | combines the features that I find lacking in the above tools, but this is only 50 | my 2 cents :) 51 | 52 | ## Usage example 53 | Consider the following example filelist: 54 | ``` 55 | .vimrc:desktop,laptop 56 | .vimrc:pi 57 | .bashrc 58 | .foo:server 59 | ``` 60 | 61 | Firstly, there will be two .vimrc files. The first one will be shared between 62 | the hosts `desktop` and `laptop`. They will both be kept exactly the same - 63 | whenever you change it on the one host, you will get the changes on the other 64 | (you will obviously first need to do a `git pull` inside the repository to get 65 | the new changes from the online repository). There will also be a separate 66 | `.vimrc` inside the dotgit repository that will only be used with the `pi` host. 67 | 68 | Since no host was specified with `.bashrc` it will reside inside the `common` 69 | folder. This means that it will be shared among all hosts using this dotgit 70 | repository (unless a category is specifically used along with the dotgit 71 | commands). 72 | 73 | Lastly the `.foo` will only be used when you explicitly use the category 74 | `server`. This makes it easy to keep separate configurations inside the same 75 | repository. 76 | 77 | If you'd like to see a dotgit repository in action you can look at my 78 | [dotfiles](https://github.com/kobus-v-schoor/dotfiles-dotgit) where I keep the dotfiles 79 | of 3 PC's that I regularly use. 80 | 81 | ## Installation 82 | Arch Linux- [AUR Package](https://aur.archlinux.org/packages/dotgit) 83 | 84 | A system-wide install is not necessary - you can simply run dotgit out of a 85 | local bin folder. If you don't have one set up you can run the following: 86 | ``` 87 | git clone https://github.com/kobus-v-schoor/dotgit 88 | mkdir -p ~/.bin 89 | cp -r dotgit/bin/dotgit* ~/.bin 90 | cat dotgit/bin/bash_completion >> ~/.bash_completion 91 | rm -rf dotgit 92 | echo 'export PATH="$PATH:$HOME/.bin"' >> ~/.bashrc 93 | ``` 94 | 95 | To install fish shell completion: 96 | ``` 97 | cp dotgit/bin/fish_completion.fish ~/.config/fish/completions/dotgit.fish 98 | ``` 99 | 100 | (Any help with packaging for a different distro will be appreciated) 101 | 102 | ## Instructions 103 | Remember that this is simply a git repository so all the usual git tricks work 104 | perfectly :) 105 | 106 | Create your online git repository, clone it (`git clone {repo_url}`) and then 107 | run `dotgit init` inside your repository (alias for `git init` and creating a 108 | file and folder needed for dotgit) 109 | 110 | Now all you have to do is edit the filelist (help message explains syntax) to 111 | your needs and you will be ready to do `dotgit update` :) The help message will 112 | explain the other options available to you, and I would recommend reading it as 113 | it has quite a few important notes. If you have any problems or feature requests 114 | please inform me of them and I will be glad to help. 115 | -------------------------------------------------------------------------------- /old/bin/bash_completion: -------------------------------------------------------------------------------- 1 | function _dotgit 2 | { 3 | COMPREPLY=() 4 | 5 | local -a opts=() 6 | 7 | local use_opts=0 8 | 9 | [[ $COMP_CWORD -eq 1 ]] && use_opts=1 10 | [[ $COMP_CWORD -eq 2 ]] && [[ ${COMP_WORDS[1]} == verbose ]] && \ 11 | use_opts=1 12 | 13 | if [[ $use_opts -eq 1 ]]; then 14 | opts+=("help") 15 | opts+=("init") 16 | opts+=("update") 17 | opts+=("restore") 18 | opts+=("clean") 19 | opts+=("hard-update") 20 | opts+=("hard-restore") 21 | opts+=("hard-clean") 22 | opts+=("encrypt") 23 | opts+=("decrypt") 24 | opts+=("passwd") 25 | opts+=("diff") 26 | opts+=("generate") 27 | 28 | [[ $COMP_CWORD -eq 1 ]] && opts+=("verbose") 29 | else 30 | local -a ls_dir=() 31 | [ -d "dotfiles" ] && ls_dir+=("dotfiles") 32 | [ -d "dmz" ] && ls_dir+=("dmz") 33 | 34 | for i in "${ls_dir[@]}"; do 35 | for f in $i/*; do 36 | [ -d "$f" ] && opts+=("${f#$i/}") 37 | done 38 | unset f 39 | done 40 | unset i 41 | 42 | local -a fl=() 43 | [ -f "filelist" ] && fl+=("filelist") 44 | [ -f "cryptlist" ] && fl+=("cryptlist") 45 | 46 | for i in "${fl[@]}"; do 47 | while read -r line; do 48 | ! [[ $line =~ \= ]] && continue; 49 | opts+=(${line%%\=*}) 50 | done < "$i" 51 | done 52 | opts+=("common") 53 | opts+=("$HOSTNAME") 54 | 55 | opts=($(IFS=$'\n'; sort -u <<<"${opts[*]}")) 56 | fi 57 | 58 | COMPREPLY=($(IFS=$' '; compgen -W "${opts[*]}" "${COMP_WORDS[COMP_CWORD]}")) 59 | } 60 | 61 | complete -F _dotgit dotgit 62 | -------------------------------------------------------------------------------- /old/bin/dotgit: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # shellcheck disable=SC2155 3 | 4 | # Dotgit is an easy-to-use and effective way to backup all your dotfiles and 5 | # manage them in a repository 6 | 7 | # Developer: Kobus van Schoor 8 | 9 | declare -r DG_START=0 # Marker for header files 10 | declare -r DG_H=$(dirname "$(readlink -f "$0")")/dotgit_headers # Headers DIR 11 | declare -r REPO="$PWD" # Original repo dir 12 | declare -r FILELIST="filelist" 13 | declare -r CRYPTLIST="cryptlist" 14 | declare -r DG_DFDIR="dotfiles" 15 | declare -r DG_DMZ="dmz" 16 | declare -r DG_VERBOSE=$([ "$1" == "verbose" ]; echo -n $?) 17 | 18 | # shellcheck source=dotgit_headers/help 19 | source "$DG_H/help" 20 | # shellcheck source=dotgit_headers/repo 21 | source "$DG_H/repo" 22 | # shellcheck source=dotgit_headers/update 23 | source "$DG_H/update" 24 | # shellcheck source=dotgit_headers/restore 25 | source "$DG_H/restore" 26 | # shellcheck source=dotgit_headers/clean 27 | source "$DG_H/clean" 28 | # shellcheck source=dotgit_headers/security 29 | source "$DG_H/security" 30 | # shellcheck source=dotgit_headers/diff 31 | source "$DG_H/diff" 32 | 33 | declare -a CTG # Active categories 34 | declare -A CTGG # Category groups 35 | 36 | declare -a FN # File names 37 | declare -a FC # Normal categories 38 | declare -a FE # File encrypt flag 39 | 40 | [[ $DG_VERBOSE -eq 0 ]] && shift 41 | 42 | [[ $# -ne 0 ]] && [[ $1 != "init" ]] && [[ $1 != "help" ]] && init_cgroups 43 | 44 | declare -a tctg 45 | if [[ $# -eq 0 ]]; then 46 | phelp 47 | exit 48 | elif [[ $# -eq 1 ]]; then 49 | tctg=(common $HOSTNAME) 50 | else 51 | tctg=(${@:2}) 52 | fi 53 | 54 | IFS=$' ' 55 | for g in "${tctg[@]}"; do 56 | if [ "${CTGG[$g]}" ]; then 57 | verecho "Expanding categories with group $g=[${CTGG[$g]}]" 58 | IFS=$',' 59 | CTG+=(${CTGG[$g]}) 60 | else 61 | # shellcheck disable=SC2034 62 | CTG+=($g) 63 | fi 64 | done 65 | 66 | IFS=$'\n' CTG=($(sort -u <<<"${CTG[*]}")) 67 | IFS=$',' 68 | verecho "Active categories: ${CTG[*]}" 69 | 70 | if [[ $1 != "init" ]] && [[ $1 != "help" ]]; then 71 | safety_checks 72 | init_flists 73 | 74 | # Check if previous version of dotgit is used 75 | if [ -f "$REPO/$DG_PASS_FILE" ] && \ 76 | [[ $(stat -c %s "$REPO/$DG_PASS_FILE") -eq 68 ]]; then 77 | echo "Updating repo to be compatible with new version of dotgit" 78 | 79 | # shellcheck disable=SC2034 80 | DG_READ_MANGLE=1 81 | get_password 82 | crypt "decrypt" 83 | rm "$REPO/$DG_PASS_FILE" 84 | unset DG_READ_MANGLE 85 | get_password 86 | crypt "encrypt" 87 | fi 88 | 89 | if [ -f "$REPO/dir_filelist" ]; then 90 | echo "Migrating dir_filelist" 91 | cat "$REPO/dir_filelist" >> "$REPO/$FILELIST" 92 | rm "$REPO/dir_filelist" 93 | fi 94 | 95 | if [ -f "$REPO/dir_cryptlist" ]; then 96 | echo "Migrating dir_cryptlist" 97 | cat "$REPO/dir_cryptlist" >> "$REPO/$CRYPTLIST" 98 | rm "$REPO/dir_cryptlist" 99 | fi 100 | fi 101 | 102 | case "$1" in 103 | "help")phelp;; 104 | "init")init;; 105 | "update")update "sym";; 106 | "restore")restore "sym";; 107 | "clean")clean_home_fast "sym";; 108 | "hard-update")update "nosym";; 109 | "hard-restore")restore "nosym";; 110 | "hard-clean")clean_home_fast "nosym";; 111 | "encrypt")crypt "encrypt";; 112 | "decrypt")crypt "decrypt";; 113 | "passwd")change_password;; 114 | "diff")print_diff;; 115 | "generate")generate_commit_msg;; 116 | *)echo -e "$1 is not a valid argument."; exit 1;; 117 | esac; 118 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/clean: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | function clean_home_fast 4 | { 5 | verecho "\nInitiating home cleanup" 6 | # shellcheck disable=SC2164 7 | cd "$HOME" 8 | 9 | local del 10 | for f in "${FN[@]}"; do 11 | del=0 12 | 13 | [ -h "$f" ] && [[ $(readlink "$f") =~ ^$REPO ]] && del=1 14 | [[ $1 == "nosym" ]] && [ -f "$f" ] && del=1 15 | 16 | [[ $del -ne 1 ]] && continue 17 | 18 | verecho "Removing \"$f\"" 19 | rm "$f" 20 | done 21 | } 22 | 23 | function clean_repo 24 | { 25 | verecho "\nInitiating repo cleanup" 26 | 27 | verecho "Cleaning dotfiles folder" 28 | clean_repo_folder "$DG_DFDIR" 29 | verecho "Cleaning dmz folder" 30 | clean_repo_folder "$DG_DMZ" 31 | } 32 | 33 | function clean_repo_folder 34 | { 35 | if ! cd "$REPO/$1"; then 36 | echo "Unable to enter $1 directory" 37 | exit 1 38 | fi 39 | 40 | IFS=$'\n' 41 | while read -r fl; do 42 | [ ! "$fl" ] && break 43 | 44 | local c=${fl%%/*} 45 | local f=${fl#*/} 46 | f=${f%\.hash} 47 | 48 | local found=0 49 | 50 | local index=0 51 | for fns in "${FN[@]}"; do 52 | if [[ $fns == "$f" ]]; then 53 | IFS=$',' 54 | for cts in ${FC[$index]}; do 55 | if [[ $cts == "$c" ]]; then 56 | found=1; 57 | break; 58 | fi 59 | done 60 | unset cts 61 | 62 | [[ $found -eq 1 ]] && break 63 | fi 64 | 65 | index=$((index + 1)) 66 | done 67 | unset fns 68 | 69 | if [[ $found -ne 1 ]]; then 70 | verecho "$(levecho 1 "Removing $fl")" 71 | rm "$fl" 72 | fi 73 | 74 | done <<< "$(find . -not -type d | cut -c 3-)" 75 | unset fl 76 | 77 | verecho "$(levecho 1 "Removing empty directories")" 78 | find . -type d -empty -delete 79 | } 80 | 81 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 82 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/diff: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | declare -a DG_DIFF_T 4 | declare -a DG_DIFF_F 5 | 6 | function init_diff 7 | { 8 | # shellcheck disable=SC2164 9 | cd "$REPO" 10 | git add --all 11 | IFS=$'\n' 12 | 13 | local fl_ch=0 14 | local cr_ch=0 15 | 16 | while read -r line; do 17 | local a=${line%% *} 18 | local f=${line#* } 19 | 20 | f=${f:1} 21 | f=${f%\"} 22 | f=${f#\"} 23 | 24 | [[ $f == "$FILELIST" ]] && fl_ch=1 && continue 25 | [[ $f == "$CRYPTLIST" ]] && cr_ch=1 && continue 26 | [[ ! $f =~ ^$DG_DFDIR* ]] && continue 27 | [[ $f =~ .*\.hash ]] && continue 28 | 29 | case "$a" in 30 | "A")DG_DIFF_T+=("added");; 31 | "M")DG_DIFF_T+=("modified");; 32 | "D")DG_DIFF_T+=("deleted");; 33 | "R")DG_DIFF_T+=("renamed");; 34 | "T")DG_DIFF_T+=("typechange");; 35 | *)errecho "Unknown git change \"$a\" - $f"; continue;; 36 | esac; 37 | 38 | DG_DIFF_F+=("${f#$DG_DFDIR\/}") 39 | done <<< "$(git status --porcelain)" 40 | unset line 41 | 42 | if [[ ${#DG_DIFF_F[@]} -eq 0 ]]; then 43 | [[ $fl_ch -ne 0 ]] && DG_DIFF_F+=("filelist") && DG_DIFF_T+=("modified") 44 | [[ $cr_ch -ne 0 ]] && DG_DIFF_F+=("cryptlist") && DG_DIFF_T+=("modified") 45 | fi 46 | 47 | git reset -q 48 | } 49 | 50 | function print_diff 51 | { 52 | init_diff 53 | 54 | IFS=$'\n' 55 | for index in $(seq 1 ${#DG_DIFF_T[@]}); do 56 | index=$((index - 1)) 57 | echo "${DG_DIFF_T[$index]^} ${DG_DIFF_F[$index]}" 58 | done 59 | unset index 60 | 61 | local f 62 | local -a c 63 | 64 | local str 65 | IFS=$'\n' 66 | for index in $(seq 1 ${#FN[@]}); do 67 | index=$((index - 1)) 68 | 69 | [[ ${FE[$index]} -ne 1 ]] && continue 70 | 71 | f=${FN[$index]} 72 | IFS=$',' c=(${FC[$index]}) 73 | 74 | for cat in "${c[@]}"; do 75 | [ ! -f "$REPO/$DG_DMZ/$cat/$f" ] && continue 76 | 77 | if [ ! -f "$REPO/$DG_DFDIR/$cat/$f" ]; then 78 | str="$str\nAdded $cat/$f" 79 | continue 80 | fi 81 | 82 | [ -h "$REPO/$DG_DFDIR/$cat/$f" ] && continue 83 | 84 | local hashed 85 | local hashfl 86 | hashed=$($DG_HASH "$REPO/$DG_DMZ/$cat/$f") 87 | hashed=${hashed%% *} 88 | hashfl=$(cat "$REPO/$DG_DFDIR/$cat/$f.hash") 89 | 90 | [[ $hashed != "$hashfl" ]] && str="$str\nModified $cat/$f" 91 | done 92 | unset cat 93 | done 94 | 95 | [ "$str" ] && echo -e "\nUnencrypted changes:\n$str" 96 | unset index 97 | } 98 | 99 | function generate_commit_msg 100 | { 101 | crypt "encrypt" 102 | init_diff 103 | 104 | local str 105 | IFS=$'\n' 106 | for index in $(seq 1 ${#DG_DIFF_T[@]}); do 107 | index=$((index - 1)) 108 | str="$str; ${DG_DIFF_T[$index]} ${DG_DIFF_F[$index]}" 109 | done 110 | unset index 111 | 112 | if git diff --quiet '@{u}..' && [[ ! $str ]]; then 113 | errecho "No changes to repository" 114 | exit 115 | fi 116 | 117 | if [[ $str ]]; then 118 | str=${str:2} 119 | str=${str^} 120 | 121 | git add --all 122 | git commit -m "$str" 123 | fi 124 | 125 | if [[ $(git remote -v) ]]; then 126 | if prompt "Remote detected. Do you want to push to remote?"; then 127 | git push 128 | fi 129 | fi 130 | } 131 | 132 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 133 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/help: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # Part of dotgit 3 | 4 | function phelp 5 | { 6 | less "$DG_H/help.txt" 7 | } 8 | 9 | 10 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 11 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/help.txt: -------------------------------------------------------------------------------- 1 | SYNOPSIS: 2 | Dotgit is an easy-to-use and versatile dotfile manager. It allows you to 3 | effortlessly move, backup and synchronise your dotfiles between different 4 | machines. The main ideas that dotgit revolves around are the following: 5 | 6 | - Use a single git repository for ALL your dotfiles (arbitrary amount of 7 | machines) 8 | - Straightforward git repo that can be used even when you don't have access 9 | to dotgit 10 | - Keep different versions of the same file in the same repo (eg. different 11 | .bashrc files for different setups) (categories) 12 | - Easy-to-use commands and setup 13 | - Do all the heavy-lifting git work (only if you want to) 14 | 15 | INITIAL SETUP: 16 | Firstly create an online git repository (eg. on GitHub). Clone this 17 | repository to your home folder ('git clone {repo_url} {repo_dest}', check 18 | online for more details). Next, cd into the repository and run 'dotgit 19 | init'. Next, setup your dogit repository by editing the filelist as 20 | explained in the "filelist syntax" section 21 | 22 | FILELIST SYNTAX: 23 | There are only two files inside your dotgit repository that you will be 24 | editing. They have the names 'filelist' and 'cryptlist'. Both use the same 25 | syntax and are identical in every way except for the fact that files 26 | specified inside 'cryptlist' will be encrypted before they are added to the 27 | repository. 28 | 29 | The filelist uses '#' at the beginning of a line do denominate a comment. 30 | Blank lines are ignored. 31 | 32 | The filelists use the following syntax: 33 | 34 | file_name:category1,category2 35 | 36 | or simply: 37 | 38 | file_name 39 | 40 | "file_name" can contain spaces and can be any file or folder inside your 41 | home directory. Categories are a very powerful tool that you can use to 42 | group files together. If no category is specified it is implicitly added to 43 | the "common" category. When you specify multiple categories for a single 44 | file dotgit will link their files together and they will share the exact 45 | same file. You can also use categories to separate different versions of the 46 | same file. For example: 47 | 48 | .vimrc:c1,c2 49 | .vimrc:c3 50 | 51 | In this example categories c1 and c2 will share the same .vimrc and c3 will 52 | have its own version of .vimrc. Categories can be anything you want it to be 53 | but its most straight-forward usage is the hostnames of the machines that 54 | you will be using dotgit on. 55 | 56 | After creating multiple categories it might become tedious to specify them 57 | all on the command-line, this is where category groups come in. You can 58 | specify a group with the following syntax: 59 | 60 | group1=category1,category2 61 | 62 | Then, instead of running 'dotgit update category1 category2' every time you 63 | can just run 'dotgit update group1'. Implicitly added categories (common and 64 | your hostname) can also be expanded, meaning that if you have a group name 65 | that matches your hostname it will be expanded for you and you can just run 66 | 'dotgit update' 67 | 68 | ENCRYPTION: 69 | Dotgit has support AES encryption of your dotfiles through PGP. To enable 70 | encryption of a file simply add the filename to the 'cryptlist' file. 71 | 72 | To incorporate encryption dotgit makes use of a "dmz" folder, a "middle-man" 73 | folder where all of your encrypted files will be decrypted and from there 74 | linked to your home folder. This "dmz" folder is inside your repository but 75 | never added to any of your commits. This also means that whenever you 76 | make a change to an encrypted dotfile you will have to re-encrypt the file 77 | (the changes you make will not be automatically added to your repo unlike 78 | with normal files). To do this you will simply need to cd into your dotfiles 79 | repository and run 'dotgit encrypt'. More details in the "options" section. 80 | 81 | DIRECTORY SUPPORT: 82 | Dotgit does have support for directories but it is not as versatile and 83 | forgiving as with normal files as it has a few caveats. Due to the fact that 84 | dotgit cannot possibly know beforehand what files will reside in a folder it 85 | needs to determine it at runtime. This means that you will need to take a 86 | few things in consideration: 87 | 88 | - When running 'dotgit update' all the files in the directory that you want 89 | there needs to be present (whether they are symlinks to the repo or the 90 | files themselves). If a file is removed from the folder and you update the 91 | repository, the file will be removed from the repository as well. 92 | - When running 'dotgit restore' the destination directory needs to be empty 93 | or non-existant, otherwise restore will not use the files in the 94 | repository and remove them. 95 | 96 | OPTIONS: 97 | Usage: dotgit (verbose) [option] (optional categories) 98 | 99 | You can prepend "verbose" to any of the options to enable verbose mode which 100 | will output dotgit's actions along the way. If you find a problem with 101 | dotgit please open an issue on github along with this "Verbose" output. 102 | 103 | If you don't add any categories after your option, two categories, "common" 104 | and your hostname will be implicitly added. When you add categories only the 105 | files that are in those categories will be taken into consideration. For 106 | instance, if you specify "c1" after "update" only files marked with the "c1" 107 | category will be updated. Options with "(ctgs)" after their name support 108 | categories as a space separated list. 109 | 110 | init - Setup a new dotgit repository inside the current directory 111 | 112 | update (ctgs) - Run this after you changed either of your filelists. This 113 | will update the repository structures to match your 114 | filelists. Do not use this if you only modified your 115 | dotfiles, it is unnecessary. If you run dotgit in symlink 116 | mode take note that running update will delete the original 117 | file inside your home folder and replace it with a link to 118 | the repository. 119 | 120 | restore (ctgs) - Run this to create links from your home folder to your 121 | repository. You need to run this whenever you want to setup 122 | a new machine or if you made changes to the filelists on 123 | another machine and you want the changes to be added to the 124 | current machine. Take note that dotgit will first remove 125 | old links to your dotfiles repository and then create the 126 | new links. You will thus need to specify all the categories 127 | that you want to restore in one run. When running restore 128 | dotgit will automatically try to decrypt your files 129 | 130 | clean - This will remove all links in your home folder that point 131 | to your dotfiles repository 132 | 133 | encrypt - This will encrypt all the files that you modified since 134 | your last encryption. Whenever you modify an encrypted 135 | dotfile and want to save the changes to your repository you 136 | will need to run this. This will encrypt all files marked 137 | for encryption inside the repository. 138 | 139 | decrypt - This will decrypt all files inside your repository and 140 | overwrite the version inside your "dmz" folder. You should 141 | run decrypt after pulling in new changes from a remote. 142 | This will decrypt all files marked for encryption inside 143 | the repository. 144 | 145 | passwd - Change your dotgit password. 146 | 147 | diff - This will print your current changes in your dotfiles 148 | repository as well as unencrypted changes. Please note that 149 | this will not show unencrypted files that were deleted. 150 | 151 | generate - This will generate a git commit message and push to a 152 | remote if it can find one 153 | 154 | help - Show this message 155 | 156 | 'update', 'restore' and the 'clean' option have a 'hard' mode, activated by 157 | prepending 'hard-' to the option, eg. 'hard-update'. When in this mode 158 | dotgit will copy the files to/from the repository rather than symlinking it. 159 | In the case of 'clean' it will also remove files and not just symlinks. This 160 | can be useful if you want to for example clone the repository onto a 161 | machine, restore your dotfiles and then delete the repository again. 162 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/repo: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # Part of dotgit 3 | 4 | function errecho 5 | { 6 | >&2 echo -e "$*" 7 | } 8 | 9 | function verecho 10 | { 11 | [[ $DG_VERBOSE -eq 0 ]] && echo -e "$*" 12 | } 13 | 14 | function levecho 15 | { 16 | local tmp= 17 | while [[ ${#tmp} -lt "$1" ]]; do 18 | tmp=$(echo -n "$tmp ") 19 | done 20 | tmp=$(echo -n "$tmp>") 21 | 22 | shift 23 | echo -n "$tmp $*" 24 | } 25 | 26 | function prompt 27 | { 28 | read -rp "$* [Y/n]: " ans 29 | 30 | [[ $ans == "Y" ]] && return 0 31 | [[ $ans == "y" ]] && return 0 32 | [[ ! $ans ]] && return 0 33 | 34 | return 1 35 | } 36 | 37 | function init 38 | { 39 | if ! cd "$REPO"; then 40 | errecho "Unable to enter repo." 41 | exit 1 42 | fi 43 | 44 | touch "$FILELIST" 45 | touch "$CRYPTLIST" 46 | if [ ! -f .gitignore ] || ! grep -q "$DG_DMZ" .gitignore; then 47 | echo "$DG_DMZ" >> .gitignore 48 | fi 49 | 50 | if [ ! -d ".git" ]; then 51 | git init 52 | git checkout -b master 53 | git add --all 54 | fi 55 | 56 | git diff --staged --quiet || git commit -m "Initial dotgit commit" || \ 57 | errecho "Failed to create initial commit, please re-init this" \ 58 | "repository after fixing the errors" 59 | } 60 | 61 | function safety_checks 62 | { 63 | if [[ $REPO == "$HOME" ]]; then 64 | errecho "Do not run dotgit in your home directory, run it in your" \ 65 | "dotfiles repository" 66 | exit 1 67 | fi 68 | 69 | if [ ! -d ".git" ]; then 70 | errecho "This does not appear to be a git repository. Aborting..." 71 | exit 1 72 | fi 73 | 74 | if [ ! -f "$FILELIST" ]; then 75 | errecho "Cannot locate filelist. Please make sure this repository" \ 76 | "was initialised by dotgit." 77 | exit 1 78 | fi 79 | 80 | if [ ! -f "$CRYPTLIST" ]; then 81 | errecho "Cannot locate cryptlist. Please make sure this repository" \ 82 | "was initialised by dotgit." 83 | exit 1 84 | fi 85 | 86 | if ! mkdir -p "$REPO/$DG_DFDIR"; then 87 | "Unable to create dotfiles dir" 88 | exit 1 89 | fi 90 | 91 | if ! mkdir -p "$REPO/$DG_DMZ"; then 92 | "Unable to create dmz dir" 93 | exit 1 94 | fi 95 | } 96 | 97 | function init_flists 98 | { 99 | verecho "\nInitiating filelists" 100 | # shellcheck disable=SC2164 101 | cd "$HOME" 102 | local n=0 103 | 104 | FN=() 105 | FC=() 106 | FE=() 107 | 108 | IFS=$'\n' 109 | for cur in "$REPO/$FILELIST" "$REPO/$CRYPTLIST"; do 110 | while read -r line; do 111 | [ ! "$line" ] && continue 112 | [[ $line =~ ^# ]] && continue 113 | # shellcheck disable=SC1001 114 | [[ $line =~ \= ]] && continue 115 | 116 | local -a l 117 | IFS=$':' l=($line) 118 | 119 | local -a arr 120 | IFS=$',' arr=(${l[1]:-"common"}) 121 | IFS=$'\n' arr=($(sort -u<<<"${arr[*]}")) 122 | 123 | # If file entry is folder inside repo and home folder has no such 124 | # file, folder or the folder is empty - then use repo contents as 125 | # filelist 126 | if [ -d "$REPO/$DG_DFDIR/${arr[0]}/${l[0]}" ]; then 127 | if [ ! -f "${l[0]}" ]; then 128 | if [ ! -d "${l[0]}" ] || [[ ! $(ls -A "${l[0]}") ]]; then 129 | verecho "$(levecho 1 "Using repo dir for ${l[0]}")" 130 | PRE="$REPO/$DG_DFDIR/${arr[0]}/" 131 | mkdir -p "${l[0]}" 132 | fi 133 | fi 134 | fi 135 | 136 | if [ ! -d "${l[0]}" ]; then 137 | FN+=("${l[0]}") 138 | IFS=$',' FC+=("${arr[*]}") 139 | FE+=($n) 140 | verecho "$(levecho 1 "Added ${l[0]} - ${arr[*]} - $n")" 141 | else 142 | IFS=$',' 143 | verecho "$(levecho 1 \ 144 | "Using directory mode for ${l[0]} - ${arr[*]} - $n")" 145 | 146 | IFS=$'\n' 147 | while read -r fls; do 148 | [[ ! $fls ]] && continue 149 | [[ $PRE ]] && fls=${fls#$PRE} 150 | FN+=("$fls") 151 | IFS=$',' FC+=("${arr[*]}") 152 | FE+=($n) 153 | verecho "$(levecho 2 "Added $fls")" 154 | done <<< "$(find "$PRE${l[0]}" -not -type d)" 155 | unset fls 156 | fi 157 | 158 | unset PRE 159 | done < "$cur" 160 | n=1 161 | done 162 | 163 | IFS=$'\n' 164 | for i1 in $(seq 0 $((${#FN[@]} - 1))); do 165 | IFS=$'\n' 166 | for i2 in $(seq $((i1 + 1)) $((${#FN[@]} - 1))); do 167 | if [[ ${FN[$i2]} == "${FN[$i1]}" ]]; then 168 | local f1=0 169 | local f2=0 170 | IFS=$',' 171 | 172 | for c1 in ${FC[$i1]}; do 173 | # shellcheck disable=SC2153 174 | for c2 in "${CTG[@]}"; do 175 | if [[ $c1 == "$c2" ]]; then 176 | f1=1 177 | break; 178 | fi 179 | done 180 | [[ $f1 -eq 1 ]] && break 181 | done 182 | 183 | for c1 in ${FC[$i2]}; do 184 | for c2 in "${CTG[@]}"; do 185 | if [[ $c1 == "$c2" ]]; then 186 | f2=1 187 | break; 188 | fi 189 | done 190 | [[ $f2 -eq 1 ]] && break 191 | done 192 | 193 | unset c1 194 | unset c2 195 | 196 | if [[ $f1 -eq 1 ]] && [[ $f2 -eq 1 ]]; then 197 | IFS=$'\n' 198 | errecho "Duplicate file entry found:" \ 199 | "${FN[$i1]}:${FC[$i1]}" \ 200 | "${FN[$i2]}:${FC[$i2]}" 201 | exit 1 202 | fi 203 | fi 204 | done 205 | unset i2 206 | done 207 | unset i1 208 | 209 | unset line 210 | unset cur 211 | } 212 | 213 | function init_cgroups 214 | { 215 | IFS=$'\n' 216 | for cur in "$REPO/$FILELIST" "$REPO/$CRYPTLIST"; do 217 | IFS=$'\n' 218 | while read -r line; do 219 | [ ! "$line" ] && continue 220 | [[ $line =~ ^# ]] && continue 221 | # shellcheck disable=SC1001 222 | ! [[ $line =~ \= ]] && continue 223 | 224 | IFS=$'=' 225 | local l=($line) 226 | # shellcheck disable=SC2034 227 | CTGG[${l[0]}]="${l[1]}" 228 | done < "$cur" 229 | done 230 | 231 | unset line 232 | unset cur 233 | } 234 | 235 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 236 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/restore: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | function restore 4 | { 5 | [[ $1 == "sym" ]] && clean_home_fast 6 | 7 | crypt "decrypt" 8 | 9 | verecho "\nEntering restore" 10 | 11 | local f 12 | local -a c 13 | local e 14 | IFS=$'\n' 15 | for index in $(seq 1 ${#FN[@]}); do 16 | index=$((index - 1)) 17 | 18 | f=${FN[$index]} 19 | IFS=$',' c=(${FC[$index]}) 20 | e=${FE[$index]} 21 | 22 | local DFDIR 23 | 24 | if [[ $e -eq 1 ]]; then 25 | DFDIR=$DG_DMZ 26 | else 27 | DFDIR=$DG_DFDIR 28 | fi 29 | 30 | verecho "$(levecho 1 "Restoring \"$f\" - ${c[*]} - $e")" 31 | 32 | local found=0 33 | for i in "${CTG[@]}"; do 34 | for k in "${c[@]}"; do 35 | if [[ $k == "$i" ]]; then 36 | found=1; 37 | break; 38 | fi 39 | done 40 | [[ $found -eq 1 ]] && break 41 | done 42 | unset i 43 | unset k 44 | 45 | if [[ $found -ne 1 ]]; then 46 | verecho "$(levecho 2 "Not in specified categories. Skipping...")" 47 | continue 48 | fi 49 | 50 | if [ ! -f "$REPO/$DFDIR/${c[0]}/$f" ]; then 51 | verecho "$(levecho 2 "File not found in repo. Skipping...")" 52 | continue 53 | fi 54 | 55 | if [ -f "$HOME/$f" ]; then 56 | prompt "File \"$f\" exists in home folder, replace?" || continue 57 | 58 | verecho "$(levecho 2 "Removing from home folder")" 59 | rm "$HOME/$f" 60 | fi 61 | 62 | mkdir -p "$HOME/$(dirname "$f")" 63 | local cmd= 64 | if [[ $1 == "sym" ]]; then 65 | verecho "$(levecho 3 "Creating symlink in home folder")" 66 | cmd="ln -s" 67 | else 68 | verecho "$(levecho 3 "Creating copy in home folder")" 69 | cmd="cp -p" 70 | fi 71 | eval "$cmd \"$REPO/$DFDIR/${c[0]}/$f\" \"$HOME/$f\"" 72 | done 73 | } 74 | 75 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 76 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/security: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | declare DG_PASS 4 | declare DG_PASS_FILE="passwd" 5 | declare -r DG_HASH="sha1sum" 6 | declare -i DG_HASH_COUNT=1500 7 | 8 | function get_password 9 | { 10 | # For compatibility with previous version of dotgit 11 | [[ $DG_PREV_PASS ]] && DG_PASS=$DG_PREV_PASS 12 | unset DG_PREV_PASS 13 | # ------------------------------------------------- 14 | 15 | if [[ ! $DG_PASS ]]; then 16 | local mod 17 | if [ -f "$REPO/$DG_PASS_FILE" ]; then 18 | mod="your" 19 | else 20 | mod="a new" 21 | fi 22 | echo -n "Please enter $mod password (nothing will be shown): " 23 | 24 | read -sr DG_PASS 25 | echo 26 | fi 27 | 28 | # For compatibility with previous version of dotgit 29 | if [[ $DG_READ_MANGLE ]]; then 30 | DG_PREV_PASS=$DG_PASS 31 | IFS=$'\n' 32 | # shellcheck disable=SC2162 33 | read DG_PASS <<< "$DG_PASS" 34 | return 35 | fi 36 | # ------------------------------------------------- 37 | 38 | IFS=$' ' 39 | local tmp=$DG_PASS 40 | 41 | local -i i=0 42 | while [[ $i -lt $DG_HASH_COUNT ]]; do 43 | tmp=($($DG_HASH <<< "${tmp[0]}")) 44 | i=$((i + 1)) 45 | done 46 | 47 | if [ -f "$REPO/$DG_PASS_FILE" ]; then 48 | [[ ${tmp[0]} == "$(cat "$REPO/$DG_PASS_FILE")" ]] && return 49 | errecho "Incorrect password, exiting..." 50 | exit 1 51 | else 52 | echo -n "${tmp[0]}" > "$REPO/$DG_PASS_FILE" 53 | fi 54 | } 55 | 56 | function change_password 57 | { 58 | get_password 59 | crypt "decrypt" 60 | rm "$REPO/$DG_PASS_FILE" 61 | get_password 62 | crypt "encrypt" "force" 63 | } 64 | 65 | function crypt 66 | { 67 | verecho "\nInitiating $1ion" 68 | local FR_D 69 | local TO_D 70 | 71 | if [[ $1 == "encrypt" ]]; then 72 | FR_D="$REPO/$DG_DMZ" 73 | TO_D="$REPO/$DG_DFDIR" 74 | else 75 | FR_D="$REPO/$DG_DFDIR" 76 | TO_D="$REPO/$DG_DMZ" 77 | fi 78 | 79 | local f 80 | local -a c 81 | 82 | IFS=$'\n' 83 | for index in $(seq 1 ${#FN[@]}); do 84 | index=$((index - 1)) 85 | 86 | [[ ${FE[$index]} -ne 1 ]] && continue 87 | 88 | f=${FN[$index]} 89 | IFS=$',' c=(${FC[$index]}) 90 | 91 | verecho "$(levecho 1 "${1^}ing $f")" 92 | 93 | local df_fl="$REPO/$DG_DFDIR/${c[0]}/$f" 94 | local dm_fl="$REPO/$DG_DMZ/${c[0]}/$f" 95 | 96 | local fr_fl="$FR_D/${c[0]}/$f" 97 | local to_fl="$TO_D/${c[0]}/$f" 98 | 99 | local hashed 100 | local hashfl 101 | 102 | if [ -f "$dm_fl" ]; then 103 | verecho "$(levecho 2 "Found file in dmz")" 104 | hashed=$($DG_HASH "$dm_fl") 105 | hashed=${hashed%% *} 106 | fi 107 | 108 | if [ -f "$df_fl.hash" ]; then 109 | verecho "$(levecho 2 "Found file in dotfiles")" 110 | # shellcheck disable=SC2155 111 | local hashfl=$(cat "$df_fl.hash") 112 | fi 113 | 114 | if [ ! "$hashed" ] && [[ $1 == "encrypt" ]]; then 115 | verecho "$(levecho 2 "File not found in dmz. Skipping")" 116 | continue 117 | fi 118 | 119 | if [ ! "$hashfl" ] && [[ $1 == "decrypt" ]]; then 120 | verecho "$(levecho 2 "File not found in dotfiles. Skipping")" 121 | continue 122 | fi 123 | 124 | if [[ $hashed == "$hashfl" ]] && [[ $2 != "force" ]]; then 125 | verecho "$(levecho 2 "File hashes match. Skipping")" 126 | continue 127 | fi 128 | 129 | [ ! "$DG_PASS" ] && get_password 130 | 131 | local gpg_cmd 132 | 133 | [[ $1 == "encrypt" ]] && gpg_cmd="-c" 134 | [[ $1 == "decrypt" ]] && gpg_cmd="-d" 135 | 136 | if [ -a "$to_fl" ] || [ -h "$to_fl" ]; then 137 | rm "$to_fl" 138 | [ -f "$to_fl.hash" ] && rm "$to_fl.hash" 139 | fi 140 | 141 | mkdir -p "$(dirname "$to_fl")" 142 | gpg -q --batch --passphrase "$DG_PASS" $gpg_cmd -o "$to_fl" "$fr_fl" 143 | chmod "$(stat -c %a "$fr_fl")" "$to_fl" 144 | 145 | [[ $1 == "encrypt" ]] && echo -n "$hashed" > "$to_fl.hash" 146 | 147 | for cat in "${c[@]:1}"; do 148 | local fl="$TO_D/$cat/$f" 149 | if [ -a "$fl" ] || [ -h "$fl" ]; then 150 | rm "$fl" 151 | [ -f "$fl.hash" ] && rm "$fl.hash" 152 | fi 153 | mkdir -p "$(dirname "$fl")" 154 | ln -rs "$to_fl" "$fl" 155 | done 156 | unset cat 157 | 158 | unset hashed 159 | unset hashfl 160 | done 161 | unset index 162 | 163 | clean_repo 164 | } 165 | 166 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 167 | -------------------------------------------------------------------------------- /old/bin/dotgit_headers/update: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | function update 4 | { 5 | [[ $1 == "sym" ]] && clean_home_fast 6 | 7 | # shellcheck disable=SC2155 8 | verecho "\nEntering update" 9 | 10 | local f 11 | local -a c 12 | local e 13 | IFS=$'\n' 14 | for index in $(seq 1 ${#FN[@]}); do 15 | index=$((index - 1)) 16 | 17 | f=${FN[$index]} 18 | IFS=$',' 19 | c=(${FC[$index]}) 20 | e=${FE[$index]} 21 | 22 | local DFDIR 23 | 24 | if [[ $e -eq 1 ]]; then 25 | DFDIR=$DG_DMZ 26 | else 27 | DFDIR=$DG_DFDIR 28 | fi 29 | 30 | verecho "$(levecho 1 "Updating \"$f\" - ${c[*]} - $e")" 31 | 32 | local found=0 33 | for i in "${CTG[@]}"; do 34 | for k in "${c[@]}"; do 35 | if [[ $k == "$i" ]]; then 36 | found=1; 37 | break; 38 | fi 39 | done 40 | [[ $found -eq 1 ]] && break 41 | done 42 | unset i 43 | unset k 44 | 45 | if [[ $found -ne 1 ]]; then 46 | verecho "$(levecho 2 "Not in specified categories. Skipping...")" 47 | continue 48 | fi 49 | 50 | if [[ ! "$1" == "sym" ]]; then 51 | # shellcheck disable=SC2164 52 | cd "$HOME" 53 | 54 | if [ ! -f "$f" ]; then 55 | verecho "$(levecho 2 "Cannot find file in home folder.")" 56 | continue 57 | fi 58 | 59 | for i in "${c[@]}"; do 60 | verecho "$(levecho 2 "Copying to category $i")" 61 | mkdir -p "$REPO/$DFDIR/$i" 62 | cp -p --parents "$f" "$REPO/$DFDIR/$i" 63 | done 64 | unset i 65 | 66 | continue 67 | fi 68 | 69 | found=0 70 | local -i fsym=0 71 | local fcat= 72 | 73 | # shellcheck disable=SC2164 74 | cd "$REPO/$DFDIR" 75 | 76 | local d_rm=0 77 | local f_rm=0 78 | for i in "${c[@]}"; do 79 | [ -d "$i/$f" ] && d_rm=1 80 | 81 | local tmp 82 | tmp=$(dirname "$i/$f") 83 | 84 | mkdir -p "$tmp" > /dev/null 2>&1 || f_rm=1 85 | 86 | if [[ $d_rm -eq 1 ]] || [[ $f_rm -eq 1 ]]; then 87 | verecho "$(levecho 2 "Type mismatch, removing repo version")" 88 | if [[ $d_rm -eq 1 ]]; then 89 | tmp="$i/$f" 90 | else 91 | while [ ! -f "$tmp" ] && [ "$tmp" != "$REPO/$DFDIR" ]; do 92 | tmp=$(dirname "$tmp") 93 | done 94 | 95 | if [[ $tmp == "$REPO/$DFDIR" ]]; then 96 | IFS=$' ' errecho "Type mismatch repo error," \ 97 | "unable to find file causing problems. Aborting..." 98 | exit 1 99 | fi 100 | fi 101 | 102 | verecho "$(levecho 3 "Removing $tmp")" 103 | rm -rf "$tmp" 104 | fi 105 | 106 | unset tmp 107 | done 108 | unset d_rm 109 | unset f_rm 110 | 111 | for i in "${c[@]}"; do 112 | if [ -f "$i/$f" ]; then 113 | found=1 114 | fcat=$i 115 | verecho "$(levecho 2 "Found in $i")" 116 | 117 | if [ -h "$i/$f" ] || [ "$i" != "${c[0]}" ]; then 118 | verecho "$(levecho 3 "Invalid root file")" 119 | fsym=1 120 | fi 121 | break 122 | fi 123 | done 124 | unset i 125 | 126 | if [ $found -eq 0 ]; then 127 | verecho "$(levecho 2 "Not found in repo, adding to repo")" 128 | 129 | # shellcheck disable=SC2164 130 | cd "$HOME" 131 | 132 | if [ ! -f "$f" ]; then 133 | verecho "$(levecho 3 "Cannot find file in home folder.")" 134 | continue 135 | fi 136 | 137 | mkdir -p "$REPO/$DFDIR/${c[0]}" 138 | cp -p --parents "$f" "$REPO/$DFDIR/${c[0]}" 139 | verecho "$(levecho 3 "Root file added to repo")" 140 | elif [[ $fsym -eq 1 ]]; then 141 | verecho "$(levecho 2 "Finding previous root file")" 142 | # shellcheck disable=SC2155 143 | local root=$(readlink -f "$REPO/$DFDIR/$fcat/$f") 144 | 145 | if [ ! -f "$root" ]; then 146 | verecho "$(levecho 3 "Cannot find root file," \ 147 | "trying to find file in home folder")" 148 | if [ ! -f "$HOME/$f" ]; then 149 | verecho "$(levecho "Cannot find file in home folder.")" 150 | continue 151 | fi 152 | root="$HOME/$f" 153 | fi 154 | 155 | verecho "$(levecho 2 "Creating new root - ${c[0]}")" 156 | 157 | mkdir -p "$(dirname "$REPO/$DFDIR/${c[0]}/$f")" 158 | rm "$REPO/$DFDIR/${c[0]}/$f" > /dev/null 2>&1 159 | cp -p "$root" "$REPO/$DFDIR/${c[0]}/$f" 160 | 161 | for i in "${c[@]:1}"; do 162 | rm "$REPO/$DFDIR/$i/$f" > /dev/null 2>&1 163 | done 164 | unset i 165 | 166 | verecho "$(levecho 3 "Root file added to repo")" 167 | fi 168 | 169 | for i in "${c[@]:1}"; do 170 | mkdir -p "$(dirname "$REPO/$DFDIR/$i/$f")" 171 | # Link other categories to "root" file (first in cat arr) 172 | if [ ! -f "$REPO/$DFDIR/$i/$f" ]; then 173 | ln -rs "$REPO/$DFDIR/${c[0]}/$f" "$REPO/$DFDIR/$i/$f" 174 | verecho "$(levecho 3 "Co-category \"$i\" linked to root")" 175 | fi 176 | done 177 | unset i 178 | 179 | verecho "$(levecho 2 "Creating/updating link to repo")" 180 | if [ -a "$HOME/$f" ] || [ -h "$HOME/$f" ]; then 181 | rm "$HOME/$f" 182 | fi 183 | ln -s "$REPO/$DFDIR/${c[0]}/$f" "$HOME/$f" 184 | done 185 | 186 | unset index 187 | clean_repo 188 | } 189 | 190 | [[ ! $DG_START ]] && echo "Do not source this directly, it is used by dotgit" 191 | -------------------------------------------------------------------------------- /old/bin/fish_completion.fish: -------------------------------------------------------------------------------- 1 | # completion for https://github.com/kobus-v-schoor/dotgit 2 | 3 | function __fish_dotgit_no_subcommand -d 'Test if dotgit has yet to be given the subcommand' 4 | for i in (commandline -opc) 5 | if contains -- $i init upgrade restore clean encrypt decrypt passwd diff generate help 6 | return 1 7 | end 8 | end 9 | return 0 10 | end 11 | 12 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'init' -d 'Setup a new dotgit repository' 13 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'update' -d 'Update the repository structure to match filelists' 14 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'restore' -d 'Create links from the home folder to the repository' 15 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'clean' -d 'Remove links in the home folder' 16 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'encrypt' -d 'Encrypt all files' 17 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'decrypt' -d 'Decrypt all files' 18 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'passwd' -d 'Change the dotgit password' 19 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'diff' -d 'Print the current changes' 20 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'generate' -d 'Generate and push the changes' 21 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'help' -d 'Display the help' 22 | -------------------------------------------------------------------------------- /old/build/arch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Kobus van Schoor 2 | pkgname=dotgit 3 | pkgver=1.4.1 4 | pkgrel=1 5 | pkgdesc="A comprehensive solution to managing your dotfiles" 6 | url="http://github.com/Cube777/dotgit" 7 | arch=('any') 8 | depends=('git' 'bash' 'gnupg') 9 | source=('git+https://github.com/Cube777/dotgit.git') 10 | md5sums=('SKIP') 11 | 12 | prepare() 13 | { 14 | cd $pkgname 15 | git --work-tree . checkout -q tags/$pkgver 16 | } 17 | 18 | package() 19 | { 20 | install -Dm 755 "$srcdir/dotgit/bin/dotgit" "$pkgdir/usr/bin/dotgit" 21 | cp -r "$srcdir/dotgit/bin/dotgit_headers" "$pkgdir/usr/bin/dotgit_headers" 22 | chmod 555 "$pkgdir/usr/bin/dotgit_headers" 23 | install -Dm644 "$srcdir/dotgit/bin/bash_completion" \ 24 | "$pkgdir/usr/share/bash-completion/completions/dotgit" 25 | } 26 | -------------------------------------------------------------------------------- /old/build/debian/changelog: -------------------------------------------------------------------------------- 1 | dotgit (1.2.3-1) testing; urgency=medium 2 | 3 | * Initial debian packaging 4 | 5 | -- Kobus van Schoor Fri, 09 Sep 2016 13:52:08 +0000 6 | -------------------------------------------------------------------------------- /old/build/debian/compat: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /old/build/debian/control: -------------------------------------------------------------------------------- 1 | Source: dotgit 2 | Section: misc 3 | Priority: extra 4 | Maintainer: Kobus van Schoor 5 | Build-Depends: debhelper (>= 7.0.50~) 6 | Standards-Version: 3.9.2 7 | Vcs-Git: git://github.com/Cube777/dotgit 8 | Vcs-Browser: https://github.com/Cube777/dotgit 9 | 10 | Package: dotgit 11 | Architecture: all 12 | Depends: git, bash, sed, grep 13 | Description: simple dotfiles manager 14 | A comprehensive bash program to store and manage all your dotfiles 15 | -------------------------------------------------------------------------------- /old/build/debian/install: -------------------------------------------------------------------------------- 1 | dotgit /usr/bin 2 | bash_completion /usr/share/bash-completion/completions/dotgit 3 | -------------------------------------------------------------------------------- /old/build/debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | case "$1" in 4 | configure) 5 | : 6 | ;; 7 | 8 | esac 9 | -------------------------------------------------------------------------------- /old/build/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | include /usr/share/cdbs/1/rules/debhelper.mk 3 | 4 | -------------------------------------------------------------------------------- /pkg/arch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Kobus van Schoor 2 | pkgname=dotgit 3 | pkgver='2.2.9' 4 | pkgrel=0 5 | pkgdesc='A comprehensive solution to managing your dotfiles' 6 | url='https://github.com/kobus-v-schoor/dotgit' 7 | arch=('any') 8 | depends=('git' 'python') 9 | optdepends=('gnupg: encryption support') 10 | makedepends=('python-setuptools') 11 | source=("https://files.pythonhosted.org/packages/source/d/dotgit/dotgit-$pkgver.tar.gz") 12 | md5sums=('SKIP') 13 | 14 | build() 15 | { 16 | cd "dotgit-$pkgver" 17 | python setup.py build 18 | } 19 | 20 | package() 21 | { 22 | cd "dotgit-$pkgver" 23 | python setup.py install --root="$pkgdir/" --optimize=1 --skip-build 24 | install -Dm644 pkg/completion/bash.sh -T \ 25 | "$pkgdir/usr/share/bash-completion/completions/dotgit" 26 | } 27 | -------------------------------------------------------------------------------- /pkg/completion/bash.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | function _dotgit { 4 | local has_action=0 5 | 6 | # iterate through the current args to check if we are trying to complete an 7 | # action or a category 8 | for word in "${COMP_WORDS[@]}"; do 9 | # skip current word 10 | [[ $word == ${COMP_WORDS[COMP_CWORD]} ]] && continue 11 | 12 | # skip the actual dotgit command 13 | [[ $word == "dotgit" ]] && continue 14 | 15 | # check if the cmd flag starts with a dash to check if it is a flag or 16 | # an action 17 | if [[ ${word} != -* ]]; then 18 | has_action=1 19 | break 20 | fi 21 | done 22 | 23 | # no action so complete for action name 24 | if [[ $has_action -eq 0 ]]; then 25 | COMPREPLY+=("init") 26 | COMPREPLY+=("update") 27 | COMPREPLY+=("restore") 28 | COMPREPLY+=("clean") 29 | COMPREPLY+=("diff") 30 | COMPREPLY+=("commit") 31 | COMPREPLY+=("passwd") 32 | else 33 | # there is alreay an action specified, so parse the filelist for 34 | # category names 35 | COMPREPLY+=("common") 36 | COMPREPLY+=("$HOSTNAME") 37 | 38 | if [[ -f filelist ]]; then 39 | while read line; do 40 | # remove leading whitespace characters 41 | line="${line#"${line%%[![:space:]]*}"}" 42 | # remove trailing whitespace characters 43 | line="${line%"${line##*[![:space:]]}"}" 44 | # remove plugins 45 | line="${line%|*}" 46 | 47 | # skip empty lines 48 | [[ -z $line ]] && continue 49 | 50 | # skip comment lines 51 | [[ $line =~ \# ]] && continue 52 | 53 | # check if it is a category group, if not parse categories 54 | if [[ $line =~ = ]]; then 55 | # remove all categories in category group 56 | COMPREPLY+=(${line%%=*}) 57 | elif [[ $line =~ : ]]; then 58 | # remove filename 59 | line=${line##*:} 60 | # split into categories 61 | IFS=',' read -ra categories <<< "$line" 62 | # add categories to completion list 63 | COMPREPLY+=("${categories[@]}") 64 | fi 65 | done < filelist 66 | fi 67 | fi 68 | 69 | # add other command-line flags 70 | COMPREPLY+=("-h" "--help") 71 | COMPREPLY+=("-v" "--verbose") 72 | COMPREPLY+=("--version") 73 | COMPREPLY+=("--dry-run") 74 | COMPREPLY+=("--hard") 75 | 76 | # filter options that start with the current word 77 | COMPREPLY=($(compgen -W "${COMPREPLY[*]}" -- ${COMP_WORDS[COMP_CWORD]})) 78 | } 79 | 80 | complete -F _dotgit dotgit 81 | -------------------------------------------------------------------------------- /pkg/completion/fish.fish: -------------------------------------------------------------------------------- 1 | # completion for https://github.com/kobus-v-schoor/dotgit 2 | # original author @ncoif 3 | 4 | function __fish_dotgit_no_subcommand -d 'Test if dotgit has yet to be given the subcommand' 5 | for i in (commandline -opc) 6 | if contains -- $i init update restore clean diff commit passwd 7 | return 1 8 | end 9 | end 10 | return 0 11 | end 12 | 13 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'init' -d 'Setup a new dotgit repository' 14 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'update' -d 'Update the repository structure to match filelists' 15 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'restore' -d 'Create links from the home folder to the repository' 16 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'clean' -d 'Remove links in the home folder' 17 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'diff' -d 'Print the current changes' 18 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'commit' -d 'Generate a commit and push the changes' 19 | complete -f -n '__fish_dotgit_no_subcommand' -c dotgit -a 'passwd' -d 'Change the dotgit encryption password' 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import dotgit.info as info 3 | 4 | with open('README.md', 'r') as readme: 5 | long_description = readme.read() 6 | 7 | setuptools.setup( 8 | name = 'dotgit', 9 | version = info.__version__, 10 | author = info.__author__, 11 | author_email = info.__author_email__, 12 | description = 'A comprehensive solution to managing your dotfiles', 13 | long_description = long_description, 14 | long_description_content_type = 'text/markdown', 15 | url = info.__url__, 16 | project_urls = { 17 | 'Documentation': 'https://dotgit.readthedocs.io', 18 | }, 19 | license = info.__license__, 20 | packages = ['dotgit', 'dotgit.plugins'], 21 | entry_points = { 22 | 'console_scripts': ['dotgit=dotgit.__main__:main'] 23 | }, 24 | scripts = ['old/dotgit.sh'], 25 | include_package_data = True, 26 | classifiers = [ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Programming Language :: Python :: 3', 29 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 30 | 'Operating System :: POSIX', 31 | 'Operating System :: MacOS', 32 | 'Topic :: Utilities', 33 | ], 34 | python_requires = '>=3.6', 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kobus-v-schoor/dotgit/9d9fea55c39dd71fbc9e7aa73bc0feba58fe5e5c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | import subprocess 4 | import shlex 5 | 6 | @pytest.fixture(scope='session', autouse=True) 7 | def setup_git_user(): 8 | run = lambda cmd: subprocess.run(shlex.split(cmd), 9 | stdout=subprocess.PIPE).stdout.decode().strip() 10 | 11 | if not run('git config user.name'): 12 | run('git config --global user.name "Test User"') 13 | if not run('git config user.email'): 14 | run('git config --global user.email "test@example.org"') 15 | -------------------------------------------------------------------------------- /tests/test_args.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | 4 | from dotgit.args import Arguments 5 | from dotgit.enums import Actions 6 | 7 | class TestArguments: 8 | valid_actions = [a.value for a in Actions] 9 | 10 | def test_verbose(self): 11 | act = self.valid_actions[0] 12 | 13 | # test default 14 | assert Arguments([act]).verbose_level == logging.WARNING 15 | 16 | # test long version 17 | assert Arguments(['--verbose', act]).verbose_level == logging.INFO 18 | 19 | # test short version 20 | assert Arguments(['-v', act]).verbose_level == logging.INFO 21 | 22 | # test multiple 23 | assert Arguments(['-vv', act]).verbose_level == logging.DEBUG 24 | 25 | # # test max 26 | assert Arguments(['-vvv', act]).verbose_level == logging.DEBUG 27 | 28 | def test_dry_run(self): 29 | act = self.valid_actions[0] 30 | 31 | assert not Arguments([act]).dry_run 32 | assert Arguments(['--dry-run', act]).dry_run 33 | 34 | def test_hard_mode(self): 35 | act = self.valid_actions[0] 36 | 37 | assert not Arguments([act]).hard_mode 38 | assert Arguments(['--hard', act]).hard_mode 39 | 40 | def test_actions(self): 41 | # test valid actions 42 | for act in self.valid_actions: 43 | assert Arguments([act]).action == Actions(act) 44 | 45 | def test_categories(self): 46 | act = self.valid_actions[0] 47 | 48 | assert Arguments([act]).categories == ['common', socket.gethostname()] 49 | assert Arguments([act, 'foo']).categories == ['foo'] 50 | -------------------------------------------------------------------------------- /tests/test_calc_ops.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotgit.calc_ops import CalcOps 5 | from dotgit.file_ops import FileOps 6 | from dotgit.plugins.plain import PlainPlugin 7 | 8 | class TestCalcOps: 9 | def setup_home_repo(self, tmp_path): 10 | os.makedirs(tmp_path / 'home') 11 | os.makedirs(tmp_path / 'repo') 12 | return tmp_path/'home', tmp_path/'repo' 13 | 14 | def test_update_no_cands(self, tmp_path, caplog): 15 | home, repo = self.setup_home_repo(tmp_path) 16 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 17 | calc.update({'file': ['cat1', 'cat2']}) 18 | assert 'unable to find any candidates' in caplog.text 19 | 20 | def test_update_master_noslave(self, tmp_path): 21 | home, repo = self.setup_home_repo(tmp_path) 22 | os.makedirs(repo / 'cat1') 23 | open(repo / 'cat1' / 'file', 'w').close() 24 | 25 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 26 | calc.update({'file': ['cat1', 'cat2']}).apply() 27 | 28 | assert (repo / 'cat1').is_dir() 29 | assert not (repo / 'cat1' / 'file').is_symlink() 30 | assert (repo / 'cat2').is_dir() 31 | assert (repo / 'cat2' / 'file').is_symlink() 32 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 33 | 34 | def test_update_nomaster_slave(self, tmp_path): 35 | home, repo = self.setup_home_repo(tmp_path) 36 | os.makedirs(repo / 'cat2') 37 | open(repo / 'cat2' / 'file', 'w').close() 38 | 39 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 40 | calc.update({'file': ['cat1', 'cat2']}).apply() 41 | 42 | assert (repo / 'cat1').is_dir() 43 | assert not (repo / 'cat1' / 'file').is_symlink() 44 | assert (repo / 'cat2').is_dir() 45 | assert (repo / 'cat2' / 'file').is_symlink() 46 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 47 | 48 | def test_update_master_linkedslave(self, tmp_path): 49 | home, repo = self.setup_home_repo(tmp_path) 50 | os.makedirs(repo / 'cat1') 51 | os.makedirs(repo / 'cat2') 52 | open(repo / 'cat1' / 'file', 'w').close() 53 | os.symlink(Path('..') / 'cat1' / 'file', repo / 'cat2' / 'file') 54 | 55 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 56 | assert calc.update({'file': ['cat1', 'cat2']}).ops == [] 57 | 58 | def test_update_master_brokenlinkslave(self, tmp_path): 59 | home, repo = self.setup_home_repo(tmp_path) 60 | os.makedirs(repo / 'cat1') 61 | os.makedirs(repo / 'cat2') 62 | open(repo / 'cat1' / 'file', 'w').close() 63 | os.symlink(Path('..') / 'cat1' / 'nonexistent', repo / 'cat2' / 'file') 64 | 65 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 66 | calc.update({'file': ['cat1', 'cat2']}).apply() 67 | 68 | assert (repo / 'cat1').is_dir() 69 | assert not (repo / 'cat1' / 'file').is_symlink() 70 | assert (repo / 'cat2').is_dir() 71 | assert (repo / 'cat2' / 'file').is_symlink() 72 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 73 | 74 | def test_update_home_nomaster_noslave(self, tmp_path): 75 | home, repo = self.setup_home_repo(tmp_path) 76 | open(home / 'file', 'w').close() 77 | 78 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 79 | calc.update({'file': ['cat1', 'cat2']}).apply() 80 | 81 | assert (repo / 'cat1').is_dir() 82 | assert not (repo / 'cat1' / 'file').is_symlink() 83 | assert (repo / 'cat2').is_dir() 84 | assert (repo / 'cat2' / 'file').is_symlink() 85 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 86 | assert not (home / 'file').exists() 87 | 88 | def test_update_linkedhome_master_noslave(self, tmp_path): 89 | home, repo = self.setup_home_repo(tmp_path) 90 | os.makedirs(repo / 'cat1') 91 | open(repo / 'cat1' / 'file', 'w').close() 92 | os.symlink(repo / 'cat1' / 'file', home / 'file') 93 | 94 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 95 | calc.update({'file': ['cat1', 'cat2']}).apply() 96 | 97 | assert (repo / 'cat1').is_dir() 98 | assert not (repo / 'cat1' / 'file').is_symlink() 99 | assert (repo / 'cat2').is_dir() 100 | assert (repo / 'cat2' / 'file').is_symlink() 101 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 102 | assert (home / 'file').is_symlink() 103 | assert (home / 'file').samefile(repo / 'cat1' / 'file') 104 | 105 | def test_update_externallinkedhome_nomaster_noslave(self, tmp_path): 106 | home, repo = self.setup_home_repo(tmp_path) 107 | 108 | (home / 'foo').touch() 109 | (home / 'file').symlink_to(home / 'foo') 110 | 111 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 112 | calc.update({'file': ['cat']}).apply() 113 | 114 | assert (repo / 'cat').is_dir() 115 | assert (repo / 'cat' / 'file').exists() 116 | assert not (repo / 'cat' / 'file').is_symlink() 117 | 118 | calc.restore({'file': ['cat']}).apply() 119 | 120 | assert (home / 'file').is_symlink() 121 | assert (home / 'file').samefile(repo / 'cat' / 'file') 122 | assert repo in (home / 'file').resolve().parents 123 | assert (home / 'foo').exists() 124 | assert not (home / 'foo').is_symlink() 125 | 126 | def test_update_changed_master(self, tmp_path): 127 | home, repo = self.setup_home_repo(tmp_path) 128 | os.makedirs(repo / 'cat2') 129 | os.makedirs(repo / 'cat3') 130 | open(repo / 'cat2' / 'file', 'w').close() 131 | os.symlink(Path('..') / 'cat2' / 'file', repo / 'cat3' / 'file') 132 | 133 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 134 | calc.update({'file': ['cat1', 'cat2', 'cat3']}).apply() 135 | 136 | assert (repo / 'cat1').is_dir() 137 | assert not (repo / 'cat1' / 'file').is_symlink() 138 | assert (repo / 'cat2').is_dir() 139 | assert (repo / 'cat2' / 'file').is_symlink() 140 | assert (repo / 'cat2' / 'file').samefile(repo / 'cat1' / 'file') 141 | assert (repo / 'cat3').is_dir() 142 | assert (repo / 'cat3' / 'file').is_symlink() 143 | assert (repo / 'cat3' / 'file').samefile(repo / 'cat1' / 'file') 144 | 145 | def test_update_multiple_candidates(self, tmp_path, monkeypatch): 146 | home, repo = self.setup_home_repo(tmp_path) 147 | 148 | (repo / 'cat1').mkdir() 149 | (repo / 'cat2').mkdir() 150 | 151 | (repo / 'cat1' / 'file').write_text('file1') 152 | (repo / 'cat2' / 'file').write_text('file2') 153 | 154 | monkeypatch.setattr('builtins.input', lambda p: '1') 155 | 156 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 157 | calc.update({'file': ['cat1', 'cat2']}).apply() 158 | 159 | assert (repo / 'cat1' / 'file').exists() 160 | assert not (repo / 'cat1' / 'file').is_symlink() 161 | assert (repo / 'cat2' / 'file').is_symlink() 162 | 163 | def test_restore_nomaster_nohome(self, tmp_path, caplog): 164 | home, repo = self.setup_home_repo(tmp_path) 165 | 166 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 167 | calc.restore({'file': ['cat1', 'cat2']}).apply() 168 | 169 | assert 'unable to find "file" in repo, skipping' in caplog.text 170 | assert not (home / 'file').is_file() 171 | 172 | def test_restore_nomaster_home(self, tmp_path, caplog): 173 | home, repo = self.setup_home_repo(tmp_path) 174 | open(home / 'file', 'w').close() 175 | 176 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 177 | calc.restore({'file': ['cat1', 'cat2']}).apply() 178 | 179 | assert 'unable to find "file" in repo, skipping' in caplog.text 180 | assert (home / 'file').is_file() 181 | 182 | def test_restore_master_nohome(self, tmp_path): 183 | home, repo = self.setup_home_repo(tmp_path) 184 | os.makedirs(repo / 'cat1') 185 | open(repo / 'cat1' / 'file', 'w').close() 186 | 187 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 188 | calc.restore({'file': ['cat1', 'cat2']}).apply() 189 | 190 | assert (home / 'file').is_file() 191 | assert (home / 'file').is_symlink() 192 | assert (home / 'file').samefile(repo / 'cat1' / 'file') 193 | assert not (repo / 'cat1' / 'file').is_symlink() 194 | 195 | def test_restore_master_linkedhome(self, tmp_path): 196 | home, repo = self.setup_home_repo(tmp_path) 197 | os.makedirs(repo / 'cat1') 198 | open(repo / 'cat1' / 'file', 'w').close() 199 | os.symlink(repo / 'cat1' / 'file', home / 'file') 200 | 201 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 202 | fops = calc.restore({'file': ['cat1', 'cat2']}) 203 | assert fops.ops == [] 204 | 205 | def test_restore_master_home_replace(self, tmp_path, monkeypatch): 206 | home, repo = self.setup_home_repo(tmp_path) 207 | os.makedirs(repo / 'cat1') 208 | open(repo / 'cat1' / 'file', 'w').close() 209 | open(home / 'file', 'w').close() 210 | 211 | monkeypatch.setattr('builtins.input', lambda p: 'y') 212 | 213 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 214 | calc.restore({'file': ['cat1', 'cat2']}).apply() 215 | 216 | assert (home / 'file').is_file() 217 | assert (home / 'file').is_symlink() 218 | assert (home / 'file').samefile(repo / 'cat1' / 'file') 219 | assert not (repo / 'cat1' / 'file').is_symlink() 220 | 221 | def test_restore_master_home_noreplace(self, tmp_path, monkeypatch): 222 | home, repo = self.setup_home_repo(tmp_path) 223 | os.makedirs(repo / 'cat1') 224 | open(repo / 'cat1' / 'file', 'w').close() 225 | open(home / 'file', 'w').close() 226 | 227 | monkeypatch.setattr('builtins.input', lambda p: 'n') 228 | 229 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 230 | calc.restore({'file': ['cat1', 'cat2']}).apply() 231 | 232 | assert (home / 'file').is_file() 233 | assert not (home / 'file').is_symlink() 234 | assert (repo / 'cat1' / 'file').is_file() 235 | assert not (repo / 'cat1' / 'file').is_symlink() 236 | 237 | def test_restore_dangling_home(self, tmp_path): 238 | home, repo = self.setup_home_repo(tmp_path) 239 | os.makedirs(repo / 'cat') 240 | (repo / 'cat' / 'foo').touch() 241 | 242 | (home / 'foo').symlink_to('/non/existent/path') 243 | assert not (home / 'foo').exists() 244 | 245 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 246 | calc.restore({'foo': ['cat']}).apply() 247 | 248 | assert (home / 'foo').is_symlink() 249 | assert (home / 'foo').exists() 250 | 251 | def test_clean_nohome(self, tmp_path): 252 | home, repo = self.setup_home_repo(tmp_path) 253 | os.makedirs(repo / 'cat1') 254 | open(repo / 'cat1' / 'file', 'w').close() 255 | 256 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 257 | calc.clean({'file': ['cat1', 'cat2']}).apply() 258 | 259 | assert not (home / 'file').is_file() 260 | assert (repo / 'cat1' / 'file').is_file() 261 | 262 | def test_clean_linkedhome(self, tmp_path): 263 | home, repo = self.setup_home_repo(tmp_path) 264 | os.makedirs(repo / 'cat1') 265 | open(repo / 'cat1' / 'file', 'w').close() 266 | os.symlink(repo / 'cat1' / 'file', home / 'file') 267 | 268 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 269 | calc.clean({'file': ['cat1', 'cat2']}).apply() 270 | 271 | assert not (home / 'file').is_file() 272 | assert (repo / 'cat1' / 'file').is_file() 273 | 274 | def test_clean_linkedotherhome(self, tmp_path): 275 | home, repo = self.setup_home_repo(tmp_path) 276 | os.makedirs(repo / 'cat1') 277 | open(repo / 'cat1' / 'file', 'w').close() 278 | os.symlink(Path('cat1') / 'file', home / 'file') 279 | 280 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 281 | calc.clean({'file': ['cat1', 'cat2']}).apply() 282 | 283 | assert (home / 'file').is_symlink() 284 | assert (repo / 'cat1' / 'file').is_file() 285 | 286 | def test_clean_filehome(self, tmp_path): 287 | home, repo = self.setup_home_repo(tmp_path) 288 | os.makedirs(repo / 'cat1') 289 | open(repo / 'cat1' / 'file', 'w').close() 290 | open(home / 'file', 'w').close() 291 | 292 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 293 | calc.clean({'file': ['cat1', 'cat2']}).apply() 294 | 295 | assert (home / 'file').is_file() 296 | assert not (home / 'file').is_symlink() 297 | assert (repo / 'cat1' / 'file').is_file() 298 | 299 | def test_clean_norepo_filehome(self, tmp_path): 300 | home, repo = self.setup_home_repo(tmp_path) 301 | open(home / 'file', 'w').close() 302 | 303 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 304 | calc.clean({'file': ['cat1', 'cat2']}).apply() 305 | 306 | assert (home / 'file').is_file() 307 | assert not (home / 'file').is_symlink() 308 | assert not (repo / 'cat1' / 'file').exists() 309 | 310 | def test_clean_hard_nohome(self, tmp_path): 311 | home, repo = self.setup_home_repo(tmp_path) 312 | os.makedirs(repo / 'cat1') 313 | open(repo / 'cat1' / 'file', 'w').close() 314 | 315 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data', hard=True)) 316 | calc.clean({'file': ['cat1', 'cat2']}).apply() 317 | 318 | assert not (home / 'file').is_file() 319 | assert (repo / 'cat1' / 'file').is_file() 320 | 321 | def test_clean_hard_linkedhome(self, tmp_path): 322 | home, repo = self.setup_home_repo(tmp_path) 323 | os.makedirs(repo / 'cat1') 324 | open(repo / 'cat1' / 'file', 'w').close() 325 | os.symlink(repo / 'cat1' / 'file', home / 'file') 326 | 327 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data', hard=True)) 328 | calc.clean({'file': ['cat1', 'cat2']}).apply() 329 | 330 | # shouldn't remove symlinks since they are not hard-copied files from 331 | # the repo 332 | assert (home / 'file').is_file() 333 | assert (repo / 'cat1' / 'file').is_file() 334 | 335 | def test_clean_hard_filehome(self, tmp_path): 336 | home, repo = self.setup_home_repo(tmp_path) 337 | os.makedirs(repo / 'cat1') 338 | open(repo / 'cat1' / 'file', 'w').close() 339 | open(home / 'file', 'w').close() 340 | 341 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data', hard=True)) 342 | calc.clean({'file': ['cat1', 'cat2']}).apply() 343 | 344 | assert not (home / 'file').is_file() 345 | assert (repo / 'cat1' / 'file').is_file() 346 | 347 | def test_clean_hard_difffilehome(self, tmp_path): 348 | home, repo = self.setup_home_repo(tmp_path) 349 | os.makedirs(repo / 'cat1') 350 | open(repo / 'cat1' / 'file', 'w').close() 351 | with open(home / 'file', 'w') as f: 352 | f.write('test data') 353 | 354 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data', hard=True)) 355 | calc.clean({'file': ['cat1', 'cat2']}).apply() 356 | 357 | assert (home / 'file').is_file() 358 | assert (home / 'file').read_text() == 'test data' 359 | assert (repo / 'cat1' / 'file').is_file() 360 | 361 | def test_clean_repo(self, tmp_path): 362 | home, repo = self.setup_home_repo(tmp_path) 363 | os.makedirs(repo / 'cat1') 364 | open(repo / 'cat1' / 'file1', 'w').close() 365 | open(repo / 'cat1' / 'file2', 'w').close() 366 | os.makedirs(repo / 'cat2') 367 | open(repo / 'cat2' / 'file1', 'w').close() 368 | 369 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 370 | calc.clean_repo(['cat1/file1']).apply() 371 | 372 | assert (repo / 'cat1' / 'file1').is_file() 373 | assert not (repo / 'cat1' / 'file2').is_file() 374 | assert not (repo / 'cat2' / 'file2').is_file() 375 | 376 | def test_clean_repo_dirs(self, tmp_path): 377 | home, repo = self.setup_home_repo(tmp_path) 378 | os.makedirs(repo / 'cat1' / 'empty') 379 | assert (repo / 'cat1' / 'empty').is_dir() 380 | 381 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 382 | calc.clean_repo([]).apply() 383 | 384 | assert not (repo / 'cat1' / 'empty').is_dir() 385 | 386 | def test_clean_repo_categories(self, tmp_path): 387 | home, repo = self.setup_home_repo(tmp_path) 388 | os.makedirs(repo / 'cat1') 389 | assert (repo / 'cat1').is_dir() 390 | 391 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data')) 392 | calc.clean_repo([]).apply() 393 | 394 | assert not (repo / 'cat1').is_dir() 395 | 396 | def test_diff(self, tmp_path): 397 | home, repo = self.setup_home_repo(tmp_path) 398 | 399 | (home / 'file').touch() 400 | (home / 'file2').touch() 401 | 402 | calc = CalcOps(repo, home, PlainPlugin(tmp_path / '.data', hard=True)) 403 | calc.update({'file': ['common'], 'file2': ['common']}).apply() 404 | calc.restore({'file': ['common'], 'file2': ['common']}).apply() 405 | 406 | (home / 'file').write_text('hello world') 407 | (home / 'file2').unlink() 408 | 409 | assert calc.diff(['common']) == [f'modified {home / "file"}'] 410 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotgit.checks import safety_checks 4 | from dotgit.enums import Actions 5 | import dotgit.info as info 6 | 7 | class TestSafetyChecks: 8 | def setup_repo(self, repo): 9 | os.makedirs(repo / '.git') 10 | open(repo / 'filelist', 'w').close() 11 | 12 | def test_home(self, tmp_path): 13 | home = tmp_path / 'home' 14 | repo = tmp_path / 'repo' 15 | 16 | assert not safety_checks(home, home, True) 17 | 18 | def test_init_empty(self, tmp_path): 19 | home = tmp_path / 'home' 20 | repo = tmp_path / 'repo' 21 | 22 | assert safety_checks(repo, home, True) 23 | 24 | def test_other_empty(self, tmp_path): 25 | home = tmp_path / 'home' 26 | repo = tmp_path / 'repo' 27 | 28 | assert not safety_checks(repo, home, False) 29 | 30 | def test_have_all(self, tmp_path): 31 | home = tmp_path / 'home' 32 | repo = tmp_path / 'repo' 33 | 34 | self.setup_repo(repo) 35 | 36 | assert safety_checks(repo, home, False) 37 | 38 | def test_nogit(self, tmp_path): 39 | home = tmp_path / 'home' 40 | repo = tmp_path / 'repo' 41 | 42 | self.setup_repo(repo) 43 | os.rmdir(repo / '.git') 44 | 45 | assert not safety_checks(repo, home, False) 46 | 47 | def test_nofilelist(self, tmp_path): 48 | home = tmp_path / 'home' 49 | repo = tmp_path / 'repo' 50 | 51 | self.setup_repo(repo) 52 | os.remove(repo / 'filelist') 53 | 54 | assert not safety_checks(repo, home, False) 55 | 56 | def test_old_dotgit(self, tmp_path, caplog): 57 | home = tmp_path / 'home' 58 | repo = tmp_path / 'repo' 59 | 60 | self.setup_repo(repo) 61 | open(repo / 'cryptlist', 'w').close() 62 | 63 | assert not safety_checks(repo, home, False) 64 | assert 'old dotgit repo' in caplog.text 65 | -------------------------------------------------------------------------------- /tests/test_file_ops.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotgit.file_ops import FileOps, Op 4 | 5 | class TestFileOps: 6 | def test_init(self, tmp_path): 7 | fop = FileOps(tmp_path) 8 | assert fop.wd == tmp_path 9 | 10 | def test_check_dest_dir(self, tmp_path): 11 | fop = FileOps(tmp_path) 12 | 13 | # check relative path directly in wd 14 | fop.check_dest_dir('file') 15 | assert fop.ops == [] 16 | fop.clear() 17 | 18 | # check relative path with non-existent dir 19 | fop.check_dest_dir(os.path.join('dir', 'file')) 20 | assert fop.ops == [(Op.MKDIR, 'dir')] 21 | fop.clear() 22 | 23 | dirname = os.path.join(tmp_path, 'dir') 24 | 25 | # check relative path with existent dir 26 | os.makedirs(dirname) 27 | fop.check_dest_dir(os.path.join('dir', 'file')) 28 | assert fop.ops == [] 29 | fop.clear() 30 | os.rmdir(dirname) 31 | 32 | # check abs path with non-existent dir 33 | fop.check_dest_dir(os.path.join(dirname, 'file')) 34 | assert fop.ops == [(Op.MKDIR, dirname)] 35 | fop.clear() 36 | 37 | # check absolute path with existent dir 38 | os.makedirs(dirname) 39 | fop.check_dest_dir(os.path.join(dirname, 'file')) 40 | assert fop.ops == [] 41 | 42 | def test_mkdir(self, tmp_path): 43 | fop = FileOps(tmp_path) 44 | fop.mkdir('dir') 45 | assert fop.ops == [(Op.MKDIR, 'dir')] 46 | 47 | def test_copy(self, tmp_path): 48 | fop = FileOps(tmp_path) 49 | 50 | # existing dest dir 51 | fop.copy('from', 'to') 52 | assert fop.ops == [(Op.COPY, ('from', 'to'))] 53 | fop.clear() 54 | 55 | # non-existing dest dir 56 | dest = os.path.join('dir', 'to') 57 | fop.copy('from', dest) 58 | assert fop.ops == [(Op.MKDIR, 'dir'), (Op.COPY, ('from', dest))] 59 | 60 | def test_move(self, tmp_path): 61 | fop = FileOps(tmp_path) 62 | 63 | # existing dest dir 64 | fop.move('from', 'to') 65 | assert fop.ops == [(Op.MOVE, ('from', 'to'))] 66 | fop.clear() 67 | 68 | # non-existing dest dir 69 | dest = os.path.join('dir', 'to') 70 | fop.move('from', dest) 71 | assert fop.ops == [(Op.MKDIR, 'dir'), (Op.MOVE, ('from', dest))] 72 | 73 | def test_link(self, tmp_path): 74 | fop = FileOps(tmp_path) 75 | 76 | # existing dest dir 77 | fop.link('from', 'to') 78 | assert fop.ops == [(Op.LINK, ('from', 'to'))] 79 | fop.clear() 80 | 81 | # non-existing dest dir 82 | dest = os.path.join('dir', 'to') 83 | fop.link('from', dest) 84 | assert fop.ops == [(Op.MKDIR, 'dir'), (Op.LINK, ('from', dest))] 85 | 86 | def test_remove(self, tmp_path): 87 | fop = FileOps(tmp_path) 88 | fop.remove('file') 89 | assert fop.ops == [(Op.REMOVE, 'file')] 90 | 91 | def test_plugin(self, tmp_path): 92 | fop = FileOps(tmp_path) 93 | 94 | class Plugin: 95 | def apply(self, source, dest): 96 | self.called = True 97 | self.source = source 98 | self.dest = dest 99 | 100 | def strify(self, op): 101 | return 'Plugin.apply' 102 | 103 | plugin = Plugin() 104 | fop.plugin(plugin.apply, 'source', 'dest') 105 | assert fop.ops == [(plugin.apply, ('source', 'dest'))] 106 | fop.apply() 107 | assert plugin.called 108 | assert plugin.source == str(tmp_path / 'source') 109 | assert plugin.dest == str(tmp_path / 'dest') 110 | 111 | def test_append(self, tmp_path): 112 | fop1 = FileOps(tmp_path) 113 | fop2 = FileOps(tmp_path) 114 | 115 | fop1.remove('file') 116 | fop2.remove('file2') 117 | 118 | assert fop1.append(fop2) is fop1 119 | assert fop1.ops == [(Op.REMOVE, 'file'), (Op.REMOVE, 'file2')] 120 | 121 | def test_str(self, tmp_path): 122 | class Plugin: 123 | def apply(self, source, dest): 124 | self.called = True 125 | self.source = source 126 | self.dest = dest 127 | 128 | def strify(self, op): 129 | return 'Plugin.apply' 130 | 131 | plugin = Plugin() 132 | fop = FileOps(tmp_path) 133 | 134 | fop.copy('foo', 'bar') 135 | fop.remove('file') 136 | fop.plugin(plugin.apply, 'source', 'dest') 137 | 138 | assert str(fop) == ('COPY "foo" -> "bar"\nREMOVE "file"\n' 139 | 'Plugin.apply "source" -> "dest"') 140 | 141 | def test_apply(self, tmp_path): 142 | ## test the creating of the following structure (x marks existing files) 143 | # dir1 (x) 144 | # -> file1 (x) 145 | # delete_file (x) (will be deleted) 146 | # delete_folder (x) (will be deleted) 147 | # -> file (x) (will be deleted) 148 | # rename (x) (will be renamed to "renamed") 149 | # 150 | # link1 -> dir1/file1 151 | # link_dir/link2 -> ../dir1/file1 152 | # new_dir 153 | # copy_dir 154 | # -> copy_dir/file (from dir1/file1) 155 | 156 | os.makedirs(tmp_path / 'dir1') 157 | open(tmp_path / 'dir1' / 'file1', 'w').close() 158 | open(tmp_path / 'delete_file', 'w').close() 159 | os.makedirs(tmp_path / 'delete_folder') 160 | open(tmp_path / 'delete_folder' / 'file', 'w').close() 161 | open(tmp_path / 'rename', 'w').close() 162 | 163 | fop = FileOps(tmp_path) 164 | 165 | fop.remove('delete_file') 166 | fop.remove('delete_folder') 167 | 168 | fop.move('rename', 'renamed') 169 | 170 | fop.link(tmp_path / 'dir1' / 'file1', 'link1') 171 | fop.link(tmp_path / 'dir1' / 'file1', os.path.join('link_dir', 'link1')) 172 | 173 | fop.mkdir('new_dir') 174 | 175 | fop.copy(os.path.join('dir1','file1'), os.path.join('copy_dir', 'file')) 176 | 177 | fop.apply() 178 | 179 | assert not os.path.isfile(tmp_path / 'delete_file') 180 | assert not os.path.isfile(tmp_path / 'delete_folder' / 'file') 181 | assert not os.path.isdir(tmp_path / 'delete_folder') 182 | 183 | assert not os.path.isfile(tmp_path / 'rename') 184 | assert os.path.isfile(tmp_path / 'renamed') 185 | 186 | assert os.path.islink(tmp_path / 'link1') 187 | assert os.readlink(tmp_path / 'link1') == os.path.join('dir1', 'file1') 188 | assert os.path.isdir(tmp_path / 'link_dir') 189 | assert os.path.islink(tmp_path / 'link_dir' / 'link1') 190 | assert (os.readlink(tmp_path / 'link_dir' / 'link1') == 191 | os.path.join('..', 'dir1', 'file1')) 192 | 193 | assert os.path.isdir(tmp_path / 'new_dir') 194 | 195 | assert os.path.isdir(tmp_path / 'copy_dir') 196 | assert os.path.isfile(tmp_path / 'copy_dir' / 'file') 197 | assert not os.path.islink(tmp_path / 'copy_dir' / 'file') 198 | -------------------------------------------------------------------------------- /tests/test_flists.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import socket 4 | 5 | from dotgit.flists import Filelist 6 | 7 | class TestFilelist: 8 | def write_flist(self, tmp_path, content): 9 | fname = os.path.join(tmp_path, 'filelist') 10 | with open(fname, 'w') as f: 11 | f.write(content) 12 | return fname 13 | 14 | def test_comments_and_empty(self, tmp_path): 15 | fname = self.write_flist(tmp_path, '# test comment\n '+ 16 | '\n # spaced comment\n') 17 | 18 | flist = Filelist(fname) 19 | assert flist.groups == {} 20 | assert flist.files == {} 21 | 22 | def test_group(self, tmp_path): 23 | # Test where group name != hostname 24 | fname = self.write_flist(tmp_path, 'group=cat1,cat2,cat3') 25 | 26 | flist = Filelist(fname) 27 | assert flist.groups == {'group': ['cat1', 'cat2', 'cat3']} 28 | assert flist.files == {} 29 | 30 | # Test where group name == hostname 31 | fname = self.write_flist(tmp_path, socket.gethostname() + '=cat1,cat2,cat3') 32 | 33 | flist = Filelist(fname) 34 | assert flist.groups == {socket.gethostname(): ['cat1', 'cat2', 'cat3', socket.gethostname()]} 35 | assert flist.files == {} 36 | 37 | def test_common_file(self, tmp_path): 38 | fname = self.write_flist(tmp_path, 'common_file/with/path') 39 | 40 | flist = Filelist(fname) 41 | assert flist.groups == {} 42 | assert flist.files == {'common_file/with/path': [{ 43 | 'categories': ['common'], 44 | 'plugin': 'plain' 45 | }]} 46 | 47 | def test_file(self, tmp_path): 48 | fname = self.write_flist(tmp_path, 'file:cat1,cat2\nfile:cat3\n') 49 | 50 | flist = Filelist(fname) 51 | assert flist.groups == {} 52 | assert flist.files == { 53 | 'file': [{ 54 | 'categories': ['cat1', 'cat2'], 55 | 'plugin': 'plain' 56 | }, { 57 | 'categories': ['cat3'], 58 | 'plugin': 'plain' 59 | }]} 60 | 61 | def test_mix(self, tmp_path): 62 | fname = self.write_flist(tmp_path, 63 | 'group=cat1,cat2\ncfile\n#comment\nnfile:cat1,cat2\n') 64 | 65 | flist = Filelist(fname) 66 | assert flist.groups == {'group': ['cat1', 'cat2']} 67 | assert flist.files == { 68 | 'cfile': [{ 69 | 'categories': ['common'], 70 | 'plugin': 'plain' 71 | }], 72 | 'nfile': [{ 73 | 'categories': ['cat1', 'cat2'], 74 | 'plugin': 'plain' 75 | }]} 76 | 77 | def test_cat_plugin(self, tmp_path): 78 | fname = self.write_flist(tmp_path, 'file:cat1,cat2|encrypt') 79 | 80 | flist = Filelist(fname) 81 | assert flist.files == { 82 | 'file': [{ 83 | 'categories': ['cat1', 'cat2'], 84 | 'plugin': 'encrypt' 85 | }]} 86 | 87 | def test_nocat_plugin(self, tmp_path): 88 | fname = self.write_flist(tmp_path, 'file|encrypt') 89 | 90 | flist = Filelist(fname) 91 | assert flist.files == { 92 | 'file': [{ 93 | 'categories': ['common'], 94 | 'plugin': 'encrypt' 95 | }]} 96 | 97 | def test_activate_groups(self, tmp_path): 98 | fname = self.write_flist(tmp_path, 'group=cat1,cat2\nfile:cat1') 99 | 100 | flist = Filelist(fname) 101 | assert flist.activate(['group']) == { 102 | 'file': { 103 | 'categories': ['cat1'], 104 | 'plugin': 'plain' 105 | }} 106 | 107 | def test_activate_normal(self, tmp_path): 108 | fname = self.write_flist(tmp_path, 'file:cat1,cat2\nfile2:cat3\n') 109 | 110 | flist = Filelist(fname) 111 | assert flist.activate(['cat2']) == { 112 | 'file': { 113 | 'categories': ['cat1', 'cat2'], 114 | 'plugin': 'plain', 115 | }} 116 | 117 | def test_activate_duplicate(self, tmp_path): 118 | fname = self.write_flist(tmp_path, 'file:cat1,cat2\nfile:cat2\n') 119 | 120 | flist = Filelist(fname) 121 | with pytest.raises(RuntimeError): 122 | flist.activate(['cat2']) 123 | 124 | def test_manifest(self, tmp_path): 125 | fname = self.write_flist(tmp_path, 126 | 'group=cat1,cat2\ncfile\nnfile:cat1,cat2\n' 127 | 'gfile:group\npfile:cat1,cat2|encrypt') 128 | 129 | flist = Filelist(fname) 130 | manifest = flist.manifest() 131 | 132 | assert type(manifest) is dict 133 | assert sorted(manifest) == sorted(['plain', 'encrypt']) 134 | 135 | assert sorted(manifest['plain']) == sorted(['common/cfile', 136 | 'cat1/nfile', 'cat2/nfile', 137 | 'cat1/gfile', 138 | 'cat2/gfile']) 139 | 140 | assert sorted(manifest['encrypt']) == sorted(['cat1/pfile', 141 | 'cat2/pfile']) 142 | -------------------------------------------------------------------------------- /tests/test_git.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import pytest 5 | 6 | from dotgit.git import Git, FileState 7 | 8 | class TestGit: 9 | def touch(self, folder, fname): 10 | open(os.path.join(folder, fname), 'w').close() 11 | 12 | def test_init(self, tmp_path): 13 | path = os.path.join(tmp_path, 'nonexistent') 14 | # check that using a non-existent path fails 15 | with pytest.raises(FileNotFoundError): 16 | git = Git(path) 17 | 18 | def test_run(self, tmp_path): 19 | # check than an invalid command fails correctly 20 | with pytest.raises(subprocess.CalledProcessError): 21 | Git(tmp_path).run('git status') 22 | 23 | def test_repo_init(self, tmp_path): 24 | path = os.path.join(tmp_path, 'repo') 25 | os.makedirs(path) 26 | git = Git(path) 27 | git.init() 28 | # check that a .git folder was created 29 | assert os.path.isdir(os.path.join(path, '.git')) 30 | # check that a valid git repo was created 31 | assert subprocess.run(['git', 'status'], cwd=path).returncode == 0 32 | 33 | def setup_git(self, tmp_path): 34 | repo = os.path.join(tmp_path, 'repo') 35 | os.makedirs(repo) 36 | 37 | git = Git(repo) 38 | git.init() 39 | 40 | return git, repo 41 | 42 | def test_reset_all(self, tmp_path): 43 | git, repo = self.setup_git(tmp_path) 44 | self.touch(repo, 'file') 45 | self.touch(repo, 'file2') 46 | assert git.status()==[(FileState.UNTRACKED,f) for f in ['file','file2']] 47 | git.add() 48 | assert git.status()==[(FileState.ADDED,f) for f in ['file','file2']] 49 | git.reset() 50 | assert git.status()==[(FileState.UNTRACKED,f) for f in ['file','file2']] 51 | 52 | def test_reset_file(self, tmp_path): 53 | git, repo = self.setup_git(tmp_path) 54 | self.touch(repo, 'file') 55 | self.touch(repo, 'file2') 56 | assert git.status()==[(FileState.UNTRACKED,f) for f in ['file','file2']] 57 | git.add() 58 | assert git.status()==[(FileState.ADDED,f) for f in ['file','file2']] 59 | git.reset('file') 60 | assert git.status()==[(FileState.UNTRACKED, 'file'), (FileState.ADDED, 61 | 'file2')] 62 | 63 | def test_add_all(self, tmp_path): 64 | git, repo = self.setup_git(tmp_path) 65 | self.touch(repo, 'file') 66 | self.touch(repo, 'file2') 67 | assert git.status()==[(FileState.UNTRACKED,f) for f in ['file','file2']] 68 | git.add() 69 | assert git.status()==[(FileState.ADDED,f) for f in ['file','file2']] 70 | 71 | def test_add_file(self, tmp_path): 72 | git, repo = self.setup_git(tmp_path) 73 | self.touch(repo, 'file') 74 | self.touch(repo, 'file2') 75 | assert git.status()==[(FileState.UNTRACKED,f) for f in ['file','file2']] 76 | git.add('file') 77 | assert git.status()==[(FileState.ADDED, 'file'), (FileState.UNTRACKED, 78 | 'file2')] 79 | 80 | def test_commit_msg(self, tmp_path): 81 | git, repo = self.setup_git(tmp_path) 82 | self.touch(repo, 'file') 83 | git.add('file') 84 | msg = 'commit message with "quotes"' 85 | git.commit(msg) 86 | proc = subprocess.run(['git', 'log', '-1', '--pretty=%s'], cwd=repo, 87 | stdout=subprocess.PIPE).stdout.decode().strip() 88 | assert proc == msg 89 | 90 | def test_commit_no_msg(self, tmp_path): 91 | git, repo = self.setup_git(tmp_path) 92 | self.touch(repo, 'file') 93 | git.add('file') 94 | git.commit() 95 | proc = subprocess.run(['git', 'log', '-1', '--pretty=%s'], cwd=repo, 96 | stdout=subprocess.PIPE).stdout.decode().strip() 97 | assert proc == 'Added file' 98 | 99 | def test_gen_commit_msg(self, tmp_path): 100 | git, repo = self.setup_git(tmp_path) 101 | self.touch(repo, 'new') 102 | self.touch(repo, 'new2') 103 | git.add() 104 | self.touch(repo, 'new3') 105 | assert git.gen_commit_message() == 'Added new, added new2' 106 | 107 | def test_status_untracked(self, tmp_path): 108 | git, repo = self.setup_git(tmp_path) 109 | self.touch(repo, 'untracked') 110 | assert git.status() == [(FileState.UNTRACKED, 'untracked')] 111 | 112 | def test_status_tracked(self, tmp_path): 113 | git, repo = self.setup_git(tmp_path) 114 | self.touch(repo, 'tracked') 115 | git.add('tracked') 116 | assert git.status() == [(FileState.ADDED, 'tracked')] 117 | 118 | # tests stage/working tree switch as well 119 | def test_status_added_deleted(self, tmp_path): 120 | git, repo = self.setup_git(tmp_path) 121 | self.touch(repo, 'delete') 122 | git.add('delete') 123 | os.remove(os.path.join(repo, 'delete')) 124 | git.status() 125 | assert git.status() == [(FileState.ADDED, 'delete')] 126 | assert git.status(staged=False) == [(FileState.DELETED, 'delete')] 127 | 128 | def test_status_renamed(self, tmp_path): 129 | git, repo = self.setup_git(tmp_path) 130 | with open(os.path.join(repo, 'rename'), 'w') as f: 131 | f.write('file content\n') 132 | git.add('rename') 133 | git.commit() 134 | os.rename(os.path.join(repo, 'rename'), os.path.join(repo, 'renamed')) 135 | git.add() 136 | assert git.status() == [(FileState.RENAMED, 'rename -> renamed')] 137 | 138 | def test_has_changes(self, tmp_path): 139 | git, repo = self.setup_git(tmp_path) 140 | assert not git.has_changes() 141 | self.touch(repo, 'foo') 142 | assert git.has_changes() 143 | git.add('foo') 144 | assert git.has_changes() 145 | git.commit() 146 | assert not git.has_changes() 147 | 148 | def test_diff(self, tmp_path): 149 | git, repo = self.setup_git(tmp_path) 150 | self.touch(repo, 'foo') 151 | assert git.diff() == ['added foo'] 152 | 153 | def test_diff_no_changes(self, tmp_path): 154 | git, repo = self.setup_git(tmp_path) 155 | assert git.diff() == ['no changes'] 156 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | from os.path import expanduser 2 | 3 | import dotgit.info 4 | 5 | class TestInfo: 6 | def test_home(self): 7 | assert dotgit.info.home == expanduser('~') 8 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotgit.__main__ import main 3 | 4 | # meant to test basic usage patterns 5 | class TestIntegration: 6 | def setup_repo(self, tmp_path, flist=""): 7 | home = tmp_path / 'home' 8 | repo = tmp_path / 'repo' 9 | os.makedirs(home) 10 | os.makedirs(repo) 11 | main(args=['init'], cwd=str(repo)) 12 | with open(repo / 'filelist', 'w') as f: 13 | f.write(flist) 14 | 15 | return home, repo 16 | 17 | # adds a file to the filelist and updates the repo (and then again) 18 | def test_add_to_flist(self, tmp_path): 19 | home, repo = self.setup_repo(tmp_path) 20 | filelist = repo / "filelist" 21 | 22 | filelist.write_text("foo") 23 | main(args=['update'], cwd=str(repo), home=str(home)) 24 | assert not (repo / "dotfiles").is_dir() 25 | 26 | (home / "foo").touch() 27 | main(args=['update'], cwd=str(repo), home=str(home)) 28 | assert (repo / "dotfiles").is_dir() 29 | assert (home / "foo").is_symlink() 30 | assert (home / "foo").exists() 31 | 32 | filelist.write_text("foo\nbar") 33 | main(args=['update'], cwd=str(repo), home=str(home)) 34 | assert (repo / "dotfiles").is_dir() 35 | 36 | (home / "bar").touch() 37 | main(args=['update'], cwd=str(repo), home=str(home)) 38 | assert (home / "foo").is_symlink() 39 | assert (home / "foo").exists() 40 | assert (home / "bar").is_symlink() 41 | assert (home / "bar").exists() 42 | 43 | # adds a file to the repo, removes it from home and then restores it 44 | def test_add_remove_restore(self, tmp_path): 45 | home, repo = self.setup_repo(tmp_path, "foo") 46 | 47 | (home / "foo").touch() 48 | main(args=['update'], cwd=str(repo), home=str(home)) 49 | 50 | assert (home / "foo").is_symlink() 51 | assert (home / "foo").exists() 52 | 53 | (home / "foo").unlink() 54 | main(args=['restore'], cwd=str(repo), home=str(home)) 55 | 56 | assert (home / "foo").is_symlink() 57 | assert (home / "foo").exists() 58 | 59 | # adds a shared category file to the repo, then makes it an invidual 60 | # category file 61 | def test_add_separate_cats(self, tmp_path): 62 | home, repo = self.setup_repo(tmp_path) 63 | filelist = repo / "filelist" 64 | 65 | (home / "foo").touch() 66 | filelist.write_text("foo:asd,common") 67 | main(args=['update'], cwd=str(repo), home=str(home)) 68 | 69 | assert (home / "foo").is_symlink() 70 | assert (home / "foo").exists() 71 | assert (home / "foo").resolve().parent.match("*/asd") 72 | 73 | filelist.write_text("foo:asd\nfoo") 74 | main(args=['update'], cwd=str(repo), home=str(home)) 75 | 76 | assert (home / "foo").is_symlink() 77 | assert (home / "foo").exists() 78 | assert (home / "foo").resolve().parent.match("*/common") 79 | 80 | assert (repo / "dotfiles" / "plain" / "asd" / "foo").exists() 81 | assert not (repo / "dotfiles" / "plain" / "asd" / "foo").is_symlink() 82 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotgit.__main__ import main 3 | from dotgit.git import Git 4 | 5 | 6 | class TestMain: 7 | def setup_repo(self, tmp_path, flist): 8 | home = tmp_path / 'home' 9 | repo = tmp_path / 'repo' 10 | os.makedirs(home) 11 | os.makedirs(repo) 12 | main(args=['init'], cwd=str(repo)) 13 | with open(repo / 'filelist', 'w') as f: 14 | f.write(flist) 15 | 16 | return home, repo 17 | 18 | def test_init_home(self, tmp_path, caplog): 19 | home = tmp_path / 'home' 20 | repo = tmp_path / 'repo' 21 | os.makedirs(home) 22 | os.makedirs(repo) 23 | 24 | assert main(args=['init'], cwd=str(home), home=str(home)) != 0 25 | assert 'safety checks failed' in caplog.text 26 | 27 | def test_init(self, tmp_path, caplog): 28 | home = tmp_path / 'home' 29 | repo = tmp_path / 'repo' 30 | os.makedirs(home) 31 | os.makedirs(repo) 32 | 33 | assert main(args=['init'], cwd=str(repo), home=str(home)) == 0 34 | git = Git(str(repo)) 35 | 36 | assert (repo / '.git').is_dir() 37 | assert (repo / 'filelist').is_file() 38 | assert git.last_commit() == 'Added filelist' 39 | 40 | assert 'existing git repo' not in caplog.text 41 | assert 'existing filelist' not in caplog.text 42 | 43 | def test_reinit(self, tmp_path, caplog): 44 | home = tmp_path / 'home' 45 | repo = tmp_path / 'repo' 46 | os.makedirs(home) 47 | os.makedirs(repo) 48 | 49 | assert main(args=['init'], cwd=str(repo), home=str(home)) == 0 50 | assert main(args=['init'], cwd=str(repo), home=str(home)) == 0 51 | git = Git(str(repo)) 52 | 53 | assert (repo / '.git').is_dir() 54 | assert (repo / 'filelist').is_file() 55 | assert git.last_commit() == 'Added filelist' 56 | assert len(git.commits()) == 1 57 | 58 | assert 'existing git repo' in caplog.text 59 | assert 'existing filelist' in caplog.text 60 | 61 | def test_update_home_norepo(self, tmp_path): 62 | home, repo = self.setup_repo(tmp_path, 'file') 63 | open(home / 'file', 'w').close() 64 | 65 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 66 | assert (home / 'file').is_symlink() 67 | assert repo in (home / 'file').resolve().parents 68 | 69 | def test_update_home_repo(self, tmp_path, monkeypatch): 70 | home, repo = self.setup_repo(tmp_path, 'file') 71 | open(home / 'file', 'w').close() 72 | 73 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 74 | 75 | monkeypatch.setattr('builtins.input', lambda p: '0') 76 | 77 | os.remove(home / 'file') 78 | open(home / 'file', 'w').close() 79 | 80 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 81 | 82 | assert (home / 'file').is_symlink() 83 | assert repo in (home / 'file').resolve().parents 84 | 85 | def test_restore_nohome_repo(self, tmp_path): 86 | home, repo = self.setup_repo(tmp_path, 'file') 87 | open(home / 'file', 'w').close() 88 | 89 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 90 | assert (home / 'file').is_symlink() 91 | assert repo in (home / 'file').resolve().parents 92 | 93 | os.remove(home / 'file') 94 | assert main(args=['restore'], cwd=str(repo), home=str(home)) == 0 95 | assert (home / 'file').is_symlink() 96 | assert repo in (home / 'file').resolve().parents 97 | 98 | def test_restore_home_repo(self, tmp_path, monkeypatch): 99 | home, repo = self.setup_repo(tmp_path, 'file') 100 | open(home / 'file', 'w').close() 101 | 102 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 103 | 104 | monkeypatch.setattr('builtins.input', lambda p: 'y') 105 | 106 | os.remove(home / 'file') 107 | open(home / 'file', 'w').close() 108 | 109 | assert main(args=['restore'], cwd=str(repo), home=str(home)) == 0 110 | 111 | assert (home / 'file').is_symlink() 112 | assert repo in (home / 'file').resolve().parents 113 | 114 | def test_restore_hard_nohome_repo(self, tmp_path): 115 | home, repo = self.setup_repo(tmp_path, 'file') 116 | data = 'test data' 117 | with open(home / 'file', 'w') as f: 118 | f.write(data) 119 | 120 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 121 | assert (home / 'file').is_symlink() 122 | assert repo in (home / 'file').resolve().parents 123 | 124 | os.remove(home / 'file') 125 | assert not (home / 'file').exists() 126 | assert main(args=['restore', '--hard'], 127 | cwd=str(repo), home=str(home)) == 0 128 | assert (home / 'file').exists() 129 | assert not (home / 'file').is_symlink() 130 | assert (home / 'file').read_text() == data 131 | 132 | def test_clean(self, tmp_path): 133 | home, repo = self.setup_repo(tmp_path, 'file') 134 | open(home / 'file', 'w').close() 135 | 136 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 137 | assert (home / 'file').is_symlink() 138 | assert repo in (home / 'file').resolve().parents 139 | 140 | assert main(args=['clean'], cwd=str(repo), home=str(home)) == 0 141 | assert not (home / 'file').exists() 142 | 143 | def test_dry_run(self, tmp_path): 144 | home, repo = self.setup_repo(tmp_path, 'file') 145 | open(home / 'file', 'w').close() 146 | 147 | assert main(args=['update', '--dry-run'], 148 | cwd=str(repo), home=str(home)) == 0 149 | assert (home / 'file').exists() 150 | assert not (home / 'file').is_symlink() 151 | 152 | def test_commit_nochanges(self, tmp_path, caplog): 153 | home, repo = self.setup_repo(tmp_path, '') 154 | assert main(args=['commit'], cwd=str(repo), home=str(home)) == 0 155 | assert 'no changes detected' in caplog.text 156 | 157 | def test_commit_changes(self, tmp_path, caplog): 158 | home, repo = self.setup_repo(tmp_path, 'file') 159 | git = Git(str(repo)) 160 | open(home / 'file', 'w').close() 161 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 162 | assert main(args=['commit'], cwd=str(repo), home=str(home)) == 0 163 | assert 'not changes detected' not in caplog.text 164 | assert 'filelist' in git.last_commit() 165 | 166 | def test_commit_ignore(self, tmp_path, caplog): 167 | home, repo = self.setup_repo(tmp_path, 'file') 168 | git = Git(str(repo)) 169 | open(home / 'file', 'w').close() 170 | os.makedirs(repo / '.plugins') 171 | open(repo / '.plugins' / 'plugf', 'w').close() 172 | 173 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 174 | assert main(args=['commit'], cwd=str(repo), home=str(home)) == 0 175 | assert 'not changes detected' not in caplog.text 176 | assert 'filelist' in git.last_commit() 177 | assert 'plugf' not in git.last_commit() 178 | 179 | def test_diff(self, tmp_path, capsys): 180 | home, repo = self.setup_repo(tmp_path, 'file\nfile2') 181 | (home / 'file').touch() 182 | (home / 'file2').touch() 183 | 184 | ret = main(args=['update', '--hard'], cwd=str(repo), home=str(home)) 185 | assert ret == 0 186 | 187 | (home / 'file').write_text('hello world') 188 | 189 | ret = main(args=['diff', '--hard'], cwd=str(repo), home=str(home)) 190 | assert ret == 0 191 | 192 | captured = capsys.readouterr() 193 | assert captured.out == ('added dotfiles/plain/common/file\n' 194 | 'added dotfiles/plain/common/file2\n' 195 | 'modified filelist\n\n' 196 | 'plain-plugin updates not yet in repo:\n' 197 | f'modified {home / "file"}\n') 198 | 199 | def test_passwd_empty(self, tmp_path, monkeypatch): 200 | home, repo = self.setup_repo(tmp_path, 'file\nfile2') 201 | 202 | password = 'password123' 203 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 204 | 205 | assert not (repo / '.plugins' / 'encrypt' / 'passwd').exists() 206 | assert main(args=['passwd'], cwd=str(repo), home=str(home)) == 0 207 | assert (repo / '.plugins' / 'encrypt' / 'passwd').exists() 208 | 209 | def test_passwd_nonempty(self, tmp_path, monkeypatch): 210 | home, repo = self.setup_repo(tmp_path, 'file|encrypt') 211 | 212 | password = 'password123' 213 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 214 | 215 | (home / 'file').touch() 216 | assert main(args=['update'], cwd=str(repo), home=str(home)) == 0 217 | 218 | repo_file = repo / 'dotfiles' / 'encrypt' / 'common' / 'file' 219 | txt = repo_file.read_text() 220 | 221 | assert main(args=['passwd'], cwd=str(repo), home=str(home)) == 0 222 | assert repo_file.read_text() != txt 223 | -------------------------------------------------------------------------------- /tests/test_plugins_encrypt.py: -------------------------------------------------------------------------------- 1 | from dotgit.plugins.encrypt import GPG, hash_file, EncryptPlugin 2 | 3 | 4 | class TestGPG: 5 | def setup_io(self, tmp_path): 6 | txt = 'hello world' 7 | 8 | input_file = (tmp_path / 'input') 9 | input_file.write_text(txt) 10 | 11 | output_file = (tmp_path / 'output') 12 | return txt, input_file, output_file 13 | 14 | def test_encrypt_decrypt(self, tmp_path): 15 | txt, input_file, output_file = self.setup_io(tmp_path) 16 | gpg = GPG(txt) 17 | 18 | # encrypt the file 19 | gpg.encrypt(str(input_file), str(output_file)) 20 | assert output_file.read_bytes() != input_file.read_bytes() 21 | 22 | # decrypt the file 23 | input_file.unlink() 24 | assert not input_file.exists() 25 | gpg.decrypt(str(output_file), str(input_file)) 26 | assert input_file.read_text() == txt 27 | 28 | class TestHash: 29 | def test_hash(self, tmp_path): 30 | f = tmp_path / 'file' 31 | f.write_text('hello world') 32 | assert (hash_file(str(f)) == 'b94d27b9934d3e08a52e52d7da7dabfac484efe3' 33 | '7a5380ee9088f7ace2efcde9') 34 | 35 | 36 | class TestEncryptPlugin: 37 | def test_setup(self, tmp_path): 38 | (tmp_path / 'hashes').write_text('{"foo": "abcde"}') 39 | plugin = EncryptPlugin(data_dir=str(tmp_path)) 40 | 41 | assert plugin.hashes == {'foo': 'abcde'} 42 | 43 | def test_apply(self, tmp_path, monkeypatch): 44 | sfile = tmp_path / 'source' 45 | dfile = tmp_path / 'dest' 46 | tfile = tmp_path / 'temp' 47 | 48 | txt = 'hello world' 49 | sfile.write_text(txt) 50 | sfile.chmod(0o600) 51 | 52 | password = 'password123' 53 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 54 | 55 | plugin = EncryptPlugin(data_dir=str(tmp_path), repo_dir=str(tmp_path)) 56 | plugin.apply(str(sfile), str(dfile)) 57 | 58 | assert sfile.read_bytes() != dfile.read_bytes() 59 | 60 | gpg = GPG(password) 61 | gpg.decrypt(str(dfile), str(tfile)) 62 | 63 | rel_path = str(dfile.relative_to(tmp_path)) 64 | 65 | assert tfile.read_text() == txt 66 | assert rel_path in plugin.hashes 67 | assert plugin.hashes[rel_path] == hash_file(str(sfile)) 68 | assert plugin.modes[rel_path] == 0o600 69 | assert (tmp_path / "hashes").read_text() 70 | 71 | def test_remove(self, tmp_path, monkeypatch): 72 | txt = 'hello world' 73 | password = 'password123' 74 | 75 | tfile = tmp_path / 'temp' 76 | sfile = tmp_path / 'source' 77 | dfile = tmp_path / 'dest' 78 | 79 | tfile.write_text(txt) 80 | tfile.chmod(0o600) 81 | 82 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 83 | plugin = EncryptPlugin(data_dir=str(tmp_path), repo_dir=str(tmp_path)) 84 | 85 | plugin.apply(str(tfile), str(sfile)) 86 | plugin.remove(str(sfile), str(dfile)) 87 | 88 | assert dfile.read_text() == tfile.read_text() 89 | assert dfile.stat().st_mode & 0o777 == 0o600 90 | 91 | def test_samefile(self, tmp_path, monkeypatch): 92 | txt = 'hello world' 93 | password = 'password123' 94 | 95 | sfile = tmp_path / 'source' 96 | dfile = tmp_path / 'dest' 97 | 98 | sfile.write_text(txt) 99 | 100 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 101 | plugin = EncryptPlugin(data_dir=str(tmp_path)) 102 | 103 | plugin.apply(str(sfile), str(dfile)) 104 | 105 | assert hash_file(str(sfile)) != hash_file(str(dfile)) 106 | assert plugin.samefile(repo_file=str(dfile), ext_file=str(sfile)) 107 | 108 | def test_verify(self, tmp_path, monkeypatch): 109 | txt = 'hello world' 110 | password = 'password123' 111 | 112 | sfile = tmp_path / 'source' 113 | dfile = tmp_path / 'dest' 114 | 115 | sfile.write_text(txt) 116 | 117 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 118 | plugin = EncryptPlugin(data_dir=str(tmp_path)) 119 | # store password by encrypting one file 120 | plugin.apply(str(sfile), str(dfile)) 121 | 122 | assert plugin.verify_password(password) 123 | assert not plugin.verify_password(password + '123') 124 | 125 | def test_change_password(self, tmp_path, monkeypatch): 126 | txt = 'hello world' 127 | password = 'password123' 128 | 129 | repo = tmp_path / 'repo' 130 | repo.mkdir() 131 | 132 | sfile = tmp_path / 'source' 133 | dfile = repo / 'dest' 134 | 135 | sfile.write_text(txt) 136 | 137 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 138 | plugin = EncryptPlugin(data_dir=str(tmp_path)) 139 | 140 | plugin.apply(str(sfile), str(dfile)) 141 | 142 | password = password + '123' 143 | plugin.change_password(repo=str(repo)) 144 | gpg = GPG(password) 145 | 146 | tfile = tmp_path / 'temp' 147 | gpg.decrypt(str(dfile), str(tfile)) 148 | 149 | assert tfile.read_text() == txt 150 | 151 | def test_clean_data(self, tmp_path, monkeypatch): 152 | txt = 'hello world' 153 | password = 'password123' 154 | 155 | repo = tmp_path / 'repo' 156 | repo.mkdir() 157 | 158 | sfile = tmp_path / 'source' 159 | sfile.write_text(txt) 160 | 161 | dfile = repo / 'dest' 162 | 163 | monkeypatch.setattr('getpass.getpass', lambda prompt: password) 164 | plugin = EncryptPlugin(data_dir=str(tmp_path), repo_dir=str(repo)) 165 | 166 | plugin.apply(str(sfile), str(dfile)) 167 | 168 | rel_path = str(dfile.relative_to(repo)) 169 | 170 | assert rel_path in plugin.hashes 171 | assert rel_path in plugin.modes 172 | 173 | plugin.clean_data(['foo']) 174 | 175 | assert rel_path not in plugin.hashes 176 | assert rel_path not in plugin.modes 177 | 178 | assert rel_path not in (tmp_path / 'hashes').read_text() 179 | assert rel_path not in (tmp_path / 'modes').read_text() 180 | -------------------------------------------------------------------------------- /tests/test_plugins_plain.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotgit.plugins.plain import PlainPlugin 4 | 5 | 6 | class TestPlainPlugin: 7 | def test_apply(self, tmp_path): 8 | plugin = PlainPlugin(str(tmp_path / 'data')) 9 | 10 | data = 'test data' 11 | 12 | with open(tmp_path / 'file', 'w') as f: 13 | f.write(data) 14 | 15 | plugin.apply(tmp_path / 'file', tmp_path / 'file2') 16 | 17 | assert (tmp_path / 'file').exists() 18 | assert (tmp_path / 'file2').exists() 19 | assert not (tmp_path / 'file').is_symlink() 20 | assert not (tmp_path / 'file2').is_symlink() 21 | 22 | with open(tmp_path / 'file2', 'r') as f: 23 | assert f.read() == data 24 | 25 | def test_remove(self, tmp_path): 26 | plugin = PlainPlugin(str(tmp_path / 'data')) 27 | 28 | open(tmp_path / 'file', 'w').close() 29 | plugin.remove(tmp_path / 'file', tmp_path / 'file2') 30 | 31 | assert (tmp_path / 'file').exists() 32 | assert (tmp_path / 'file2').exists() 33 | assert not (tmp_path / 'file').is_symlink() 34 | assert (tmp_path / 'file2').is_symlink() 35 | assert (tmp_path / 'file').samefile(tmp_path / 'file2') 36 | 37 | def test_samefile_link(self, tmp_path): 38 | plugin = PlainPlugin(str(tmp_path / 'data')) 39 | 40 | open(tmp_path / 'file', 'w').close() 41 | os.symlink(tmp_path / 'file', tmp_path / 'file2') 42 | 43 | assert plugin.samefile(tmp_path / 'file', tmp_path / 'file2') 44 | 45 | def test_samefile_copy(self, tmp_path): 46 | plugin = PlainPlugin(str(tmp_path / 'data')) 47 | 48 | open(tmp_path / 'file', 'w').close() 49 | open(tmp_path / 'file2', 'w').close() 50 | 51 | assert not plugin.samefile(tmp_path / 'file', tmp_path / 'file2') 52 | 53 | def test_hard_mode(self, tmp_path): 54 | plugin = PlainPlugin(str(tmp_path / 'data'), hard=True) 55 | 56 | open(tmp_path / 'file', 'w').close() 57 | plugin.remove(tmp_path / 'file', tmp_path / 'file2') 58 | 59 | assert (tmp_path / 'file').exists() 60 | assert (tmp_path / 'file2').exists() 61 | assert not (tmp_path / 'file').is_symlink() 62 | assert not (tmp_path / 'file2').is_symlink() 63 | assert not (tmp_path / 'file').samefile(tmp_path / 'file2') 64 | --------------------------------------------------------------------------------