├── .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 | 
4 | [](https://coveralls.io/github/kobus-v-schoor/dotgit)
5 | 
6 | [](https://dotgit.readthedocs.io/en/latest/)
7 | [](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 |
--------------------------------------------------------------------------------