├── .codeclimate.yml ├── .coveragerc ├── .github └── workflows │ └── black.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── Troubleshooting.md ├── design-guide.md └── development-guide.md ├── img └── shallow-backup-demo.gif ├── requirements.txt ├── scripts └── release.sh ├── setup.cfg ├── setup.py ├── shallow_backup ├── __init__.py ├── __main__.py ├── backup.py ├── compatibility.py ├── config.py ├── constants.py ├── git_wrapper.py ├── printing.py ├── prompts.py ├── reinstall.py ├── upgrade.py └── utils.py └── tests ├── __init__.py ├── test_backups.py ├── test_config.py ├── test_copies.py ├── test_delete_backup.py ├── test_git_folder_moving.py ├── test_reinstall_dotfiles.py ├── test_utils.py └── testing_utility_functions.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # This is a sample .codeclimate.yml configured for Engine analysis on Code 2 | # Climate Platform. For an overview of the Code Climate Platform, see here: 3 | # http://docs.codeclimate.com/article/300-the-codeclimate-platform 4 | # Under the engines key, you can configure which engines will analyze your repo. 5 | version: "2" 6 | exclude_patterns: 7 | - build/* 8 | - dist/* 9 | - shallow_backup.egg-info/* 10 | 11 | plugins: 12 | radon: 13 | enabled: true 14 | config: 15 | threshold: "C" 16 | sonar-python: 17 | enabled: true 18 | config: 19 | tests_patterns: 20 | - tests/* 21 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # omit anything in a .local directory anywhere 4 | */.local/* 5 | */python3.6/* 6 | tests/* 7 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: black 2 | on: [push, pull_request] 3 | jobs: 4 | linter_name: 5 | name: runner / black formatter 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: rickstaa/action-black@v1 10 | with: 11 | black_args: ". --check" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/* 2 | .DS_Store 3 | *.pyc 4 | *.xml 5 | *.iml 6 | *.whl 7 | shallow_backup.egg-info/* 8 | build/bdist.macosx-10.6-intel 9 | build/lib 10 | .vscode 11 | .coverage 12 | .coverage.* 13 | code_coverage/* 14 | dist 15 | .pyre 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "3.6" 5 | git: 6 | depth: false 7 | 8 | install: 9 | - pip install pipenv 10 | - pip install coveralls 11 | - pipenv install --dev 12 | 13 | script: 14 | - pytest --cov 15 | 16 | after_success: 17 | - coveralls 18 | 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v6.4](https://github.com/alichtman/shallow-backup/tree/v6.4) (2024-07-12) 4 | 5 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v6.2...v6.4) 6 | 7 | **Fixed bugs:** 8 | 9 | - Reap atom config stuff [\#210](https://github.com/alichtman/shallow-backup/issues/210) 10 | 11 | **Closed issues:** 12 | 13 | - Reinstallation for absolute paths is broken [\#345](https://github.com/alichtman/shallow-backup/issues/345) 14 | - Change config file path to `shallow-backup.json` instead of `.conf` [\#344](https://github.com/alichtman/shallow-backup/issues/344) 15 | - Crash on dotfile reinstallation: `FileExistsError` [\#343](https://github.com/alichtman/shallow-backup/issues/343) 16 | - Add support for arrow keys in "Custom commit message?" prompt \(and all other prompts, also\) [\#341](https://github.com/alichtman/shallow-backup/issues/341) 17 | - Improve `git commit` experience [\#340](https://github.com/alichtman/shallow-backup/issues/340) 18 | - Replace `--show` with `--edit` [\#336](https://github.com/alichtman/shallow-backup/issues/336) 19 | - Add interactive commit messages for --backup-dots [\#335](https://github.com/alichtman/shallow-backup/issues/335) 20 | - Successful commit reported when no commit happened [\#332](https://github.com/alichtman/shallow-backup/issues/332) 21 | - Add tab-completion for zsh [\#324](https://github.com/alichtman/shallow-backup/issues/324) 22 | - Move from pipenv to poetry for dependency management [\#241](https://github.com/alichtman/shallow-backup/issues/241) 23 | 24 | **Merged pull requests:** 25 | 26 | - Fix typos, bump action workflow [\#342](https://github.com/alichtman/shallow-backup/pull/342) ([deining](https://github.com/deining)) 27 | - Add --edit argument [\#338](https://github.com/alichtman/shallow-backup/pull/338) ([alichtman](https://github.com/alichtman)) 28 | - Allow custom commit messages [\#337](https://github.com/alichtman/shallow-backup/pull/337) ([alichtman](https://github.com/alichtman)) 29 | - Update README.md [\#334](https://github.com/alichtman/shallow-backup/pull/334) ([alichtman](https://github.com/alichtman)) 30 | 31 | ## [v6.2](https://github.com/alichtman/shallow-backup/tree/v6.2) (2023-10-11) 32 | 33 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v6.1...v6.2) 34 | 35 | **Closed issues:** 36 | 37 | - Branch support? [\#296](https://github.com/alichtman/shallow-backup/issues/296) 38 | - Write zsh completions for shallow-backup [\#277](https://github.com/alichtman/shallow-backup/issues/277) 39 | - Screen for high entropy strings before pushing to remote [\#199](https://github.com/alichtman/shallow-backup/issues/199) 40 | 41 | ## [v6.1](https://github.com/alichtman/shallow-backup/tree/v6.1) (2023-10-09) 42 | 43 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v6.0...v6.1) 44 | 45 | **Fixed bugs:** 46 | 47 | - 🙋‍♀️ running shallow-backup interactively \(backup all\) does not save to the folder [\#282](https://github.com/alichtman/shallow-backup/issues/282) 48 | - Git commit breaks sometimes if submodules exist [\#229](https://github.com/alichtman/shallow-backup/issues/229) 49 | - npm packages backup: Doesn't handle scopes, fails with unmet peer dependencies [\#226](https://github.com/alichtman/shallow-backup/issues/226) 50 | 51 | **Merged pull requests:** 52 | 53 | - Fix: Update README.md file to reflect work done on issue \#328 [\#330](https://github.com/alichtman/shallow-backup/pull/330) ([BreakingPitt](https://github.com/BreakingPitt)) 54 | 55 | ## [v6.0](https://github.com/alichtman/shallow-backup/tree/v6.0) (2023-08-29) 56 | 57 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v5.3...v6.0) 58 | 59 | **Closed issues:** 60 | 61 | - Convert options from `-` to `--` where it makes sense [\#328](https://github.com/alichtman/shallow-backup/issues/328) 62 | - `backup-fonts` is in the wrong spot in the help menu [\#325](https://github.com/alichtman/shallow-backup/issues/325) 63 | - KeyError: 'all' on commit procedure [\#306](https://github.com/alichtman/shallow-backup/issues/306) 64 | 65 | **Merged pull requests:** 66 | 67 | - fix: Convert options from - to -- where it makes sense [\#329](https://github.com/alichtman/shallow-backup/pull/329) ([BreakingPitt](https://github.com/BreakingPitt)) 68 | - Move backup-fonts to the right spot in the help menu [\#327](https://github.com/alichtman/shallow-backup/pull/327) ([alichtman](https://github.com/alichtman)) 69 | - Create black.yml [\#326](https://github.com/alichtman/shallow-backup/pull/326) ([alichtman](https://github.com/alichtman)) 70 | - Deprecate apm backups and restores [\#320](https://github.com/alichtman/shallow-backup/pull/320) ([alichtman](https://github.com/alichtman)) 71 | - Bump cryptography from 38.0.4 to 39.0.1 [\#318](https://github.com/alichtman/shallow-backup/pull/318) ([dependabot[bot]](https://github.com/apps/dependabot)) 72 | - Bump gitpython from 3.1.29 to 3.1.30 [\#317](https://github.com/alichtman/shallow-backup/pull/317) ([dependabot[bot]](https://github.com/apps/dependabot)) 73 | - Fix npm backup issue \#226 [\#315](https://github.com/alichtman/shallow-backup/pull/315) ([nick-The-One](https://github.com/nick-The-One)) 74 | 75 | ## [v5.3](https://github.com/alichtman/shallow-backup/tree/v5.3) (2022-12-14) 76 | 77 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v5.2...v5.3) 78 | 79 | ## [v5.2](https://github.com/alichtman/shallow-backup/tree/v5.2) (2022-06-19) 80 | 81 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v5.1...v5.2) 82 | 83 | **Fixed bugs:** 84 | 85 | - Support default branches other than `master` [\#305](https://github.com/alichtman/shallow-backup/issues/305) 86 | 87 | **Closed issues:** 88 | 89 | - Add warning if shallow-backup.conf is writable by all [\#309](https://github.com/alichtman/shallow-backup/issues/309) 90 | - `shallow-backup` crashes if repo can't be created [\#308](https://github.com/alichtman/shallow-backup/issues/308) 91 | 92 | **Merged pull requests:** 93 | 94 | - Add warning if config is vulnerable to exploitation [\#310](https://github.com/alichtman/shallow-backup/pull/310) ([alichtman](https://github.com/alichtman)) 95 | - fix: push to different branch names [\#307](https://github.com/alichtman/shallow-backup/pull/307) ([jnoortheen](https://github.com/jnoortheen)) 96 | 97 | ## [v5.1](https://github.com/alichtman/shallow-backup/tree/v5.1) (2021-12-11) 98 | 99 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v5.0.1...v5.1) 100 | 101 | **Fixed bugs:** 102 | 103 | - Uncaught `shutil.Error` if file is not found during backup [\#298](https://github.com/alichtman/shallow-backup/issues/298) 104 | - fonts with spaces in filename are not backed up. [\#280](https://github.com/alichtman/shallow-backup/issues/280) 105 | - Failing npm Reinstall [\#148](https://github.com/alichtman/shallow-backup/issues/148) 106 | - Back up gemlist properly [\#38](https://github.com/alichtman/shallow-backup/issues/38) 107 | 108 | **Closed issues:** 109 | 110 | - Bug - dotfiles fail to save `[Errno 2] No such file or directory:` [\#297](https://github.com/alichtman/shallow-backup/issues/297) 111 | - Error when trying to show current backup: AttributeError: 'list' object has no attribute 'items'~ [\#295](https://github.com/alichtman/shallow-backup/issues/295) 112 | - npm backup overwriting pip3 backup [\#284](https://github.com/alichtman/shallow-backup/issues/284) 113 | - clarify install instructions [\#283](https://github.com/alichtman/shallow-backup/issues/283) 114 | - Allow keys to be missing from the config to declutter [\#279](https://github.com/alichtman/shallow-backup/issues/279) 115 | - Add config key for dotfiles to not reinstall [\#252](https://github.com/alichtman/shallow-backup/issues/252) 116 | - Backup brew tap [\#218](https://github.com/alichtman/shallow-backup/issues/218) 117 | - Automatic archiving after a new backup [\#176](https://github.com/alichtman/shallow-backup/issues/176) 118 | 119 | **Merged pull requests:** 120 | 121 | - Make file copy errors user friendly [\#303](https://github.com/alichtman/shallow-backup/pull/303) ([alichtman](https://github.com/alichtman)) 122 | - Catch shutil.Error if file is not found [\#302](https://github.com/alichtman/shallow-backup/pull/302) ([alichtman](https://github.com/alichtman)) 123 | - Clarify install instructions [\#301](https://github.com/alichtman/shallow-backup/pull/301) ([alichtman](https://github.com/alichtman)) 124 | - Make backup and reinstall condition keys optional [\#300](https://github.com/alichtman/shallow-backup/pull/300) ([alichtman](https://github.com/alichtman)) 125 | - Handle piping commands properly [\#299](https://github.com/alichtman/shallow-backup/pull/299) ([alichtman](https://github.com/alichtman)) 126 | - Update homebrew support [\#294](https://github.com/alichtman/shallow-backup/pull/294) ([tim-coutinho](https://github.com/tim-coutinho)) 127 | - Add cargo backup / reinstall [\#291](https://github.com/alichtman/shallow-backup/pull/291) ([tim-coutinho](https://github.com/tim-coutinho)) 128 | - Fix interactive backup all not working [\#287](https://github.com/alichtman/shallow-backup/pull/287) ([tim-coutinho](https://github.com/tim-coutinho)) 129 | - Fix npm backup overwriting pip3 backup [\#285](https://github.com/alichtman/shallow-backup/pull/285) ([tim-coutinho](https://github.com/tim-coutinho)) 130 | 131 | ## [v5.0.1](https://github.com/alichtman/shallow-backup/tree/v5.0.1) (2020-05-14) 132 | 133 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v5.0.0a...v5.0.1) 134 | 135 | **Merged pull requests:** 136 | 137 | - Bump version to 5.0.1 [\#278](https://github.com/alichtman/shallow-backup/pull/278) ([alichtman](https://github.com/alichtman)) 138 | 139 | ## [v5.0.0a](https://github.com/alichtman/shallow-backup/tree/v5.0.0a) (2020-05-13) 140 | 141 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v4.0.4...v5.0.0a) 142 | 143 | **Fixed bugs:** 144 | 145 | - Setting backup path to ~/.shallow\_backup breaks subsequent runs [\#265](https://github.com/alichtman/shallow-backup/issues/265) 146 | - shutil.SameFileError shallow-backup.conf [\#260](https://github.com/alichtman/shallow-backup/issues/260) 147 | 148 | **Closed issues:** 149 | 150 | - Add `--dry_run` key to not actually copy any files on backup or reinstall [\#274](https://github.com/alichtman/shallow-backup/issues/274) 151 | - Cannot interactively set backup path to existing backup repo [\#266](https://github.com/alichtman/shallow-backup/issues/266) 152 | - Add tests for adding paths to config [\#249](https://github.com/alichtman/shallow-backup/issues/249) 153 | - Run Travis on both Mac and Linux [\#197](https://github.com/alichtman/shallow-backup/issues/197) 154 | 155 | **Merged pull requests:** 156 | 157 | - Standardize flags [\#276](https://github.com/alichtman/shallow-backup/pull/276) ([alichtman](https://github.com/alichtman)) 158 | - Add -dry\_run flag [\#275](https://github.com/alichtman/shallow-backup/pull/275) ([alichtman](https://github.com/alichtman)) 159 | - Add conditional backup and reinstallation [\#272](https://github.com/alichtman/shallow-backup/pull/272) ([alichtman](https://github.com/alichtman)) 160 | - Refactor [\#271](https://github.com/alichtman/shallow-backup/pull/271) ([alichtman](https://github.com/alichtman)) 161 | - Be clear that changing backup path moves the folder [\#268](https://github.com/alichtman/shallow-backup/pull/268) ([ThatsJustCheesy](https://github.com/ThatsJustCheesy)) 162 | - Allow setting backup path to ~/.shallow-backup [\#267](https://github.com/alichtman/shallow-backup/pull/267) ([ThatsJustCheesy](https://github.com/ThatsJustCheesy)) 163 | 164 | ## [v4.0.4](https://github.com/alichtman/shallow-backup/tree/v4.0.4) (2020-03-29) 165 | 166 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v4.0.3...v4.0.4) 167 | 168 | **Fixed bugs:** 169 | 170 | - FileNotFoundError: ~/shallow-backup/dotfiles/.gitignore [\#257](https://github.com/alichtman/shallow-backup/issues/257) 171 | 172 | **Merged pull requests:** 173 | 174 | - Create dotfiles dir before creating .gitignore [\#259](https://github.com/alichtman/shallow-backup/pull/259) ([alichtman](https://github.com/alichtman)) 175 | - Fix default config creation [\#258](https://github.com/alichtman/shallow-backup/pull/258) ([alichtman](https://github.com/alichtman)) 176 | 177 | ## [v4.0.3](https://github.com/alichtman/shallow-backup/tree/v4.0.3) (2020-03-26) 178 | 179 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v4.0.2...v4.0.3) 180 | 181 | ## [v4.0.2](https://github.com/alichtman/shallow-backup/tree/v4.0.2) (2020-03-25) 182 | 183 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v4.0.1...v4.0.2) 184 | 185 | **Merged pull requests:** 186 | 187 | - Follow symlinks and avoid PermissionError when reinstalling .git repos [\#256](https://github.com/alichtman/shallow-backup/pull/256) ([alichtman](https://github.com/alichtman)) 188 | 189 | ## [v4.0.1](https://github.com/alichtman/shallow-backup/tree/v4.0.1) (2020-03-25) 190 | 191 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v4.0...v4.0.1) 192 | 193 | **Merged pull requests:** 194 | 195 | - Correctly exclude files on reinstallation and add tests [\#255](https://github.com/alichtman/shallow-backup/pull/255) ([alichtman](https://github.com/alichtman)) 196 | - Avoid reinstalling img/ and README from dotfiles [\#254](https://github.com/alichtman/shallow-backup/pull/254) ([alichtman](https://github.com/alichtman)) 197 | 198 | ## [v4.0](https://github.com/alichtman/shallow-backup/tree/v4.0) (2020-03-22) 199 | 200 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v3.4...v4.0) 201 | 202 | **Closed issues:** 203 | 204 | - Interface for selecting which dotfiles to back up [\#228](https://github.com/alichtman/shallow-backup/issues/228) 205 | - Use symlinking instead of copying [\#188](https://github.com/alichtman/shallow-backup/issues/188) 206 | 207 | **Merged pull requests:** 208 | 209 | - Carefully reinstall .git and .gitignore files [\#251](https://github.com/alichtman/shallow-backup/pull/251) ([alichtman](https://github.com/alichtman)) 210 | 211 | ## [v3.4](https://github.com/alichtman/shallow-backup/tree/v3.4) (2020-03-22) 212 | 213 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v3.3...v3.4) 214 | 215 | ## [v3.3](https://github.com/alichtman/shallow-backup/tree/v3.3) (2020-03-21) 216 | 217 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v3.2...v3.3) 218 | 219 | **Fixed bugs:** 220 | 221 | - Error when reinstalling all [\#216](https://github.com/alichtman/shallow-backup/issues/216) 222 | - copytree\(\) doesn't overwrite, so reinstall sometimes fails [\#209](https://github.com/alichtman/shallow-backup/issues/209) 223 | 224 | **Closed issues:** 225 | 226 | - Add `--add` flag for adding new paths to be backed up [\#247](https://github.com/alichtman/shallow-backup/issues/247) 227 | - Add Support for Hammerspoon dotfolder [\#244](https://github.com/alichtman/shallow-backup/issues/244) 228 | 229 | **Merged pull requests:** 230 | 231 | - Refactor --add option and bump to v3.3 [\#250](https://github.com/alichtman/shallow-backup/pull/250) ([alichtman](https://github.com/alichtman)) 232 | - Add CLI option for adding paths to config [\#248](https://github.com/alichtman/shallow-backup/pull/248) ([alichtman](https://github.com/alichtman)) 233 | - Fix IsADirectory error upon reinstallation [\#246](https://github.com/alichtman/shallow-backup/pull/246) ([alichtman](https://github.com/alichtman)) 234 | 235 | ## [v3.2](https://github.com/alichtman/shallow-backup/tree/v3.2) (2019-11-17) 236 | 237 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v3.1...v3.2) 238 | 239 | **Merged pull requests:** 240 | 241 | - Move config to ~/.config/shallow-backup.conf [\#242](https://github.com/alichtman/shallow-backup/pull/242) ([alichtman](https://github.com/alichtman)) 242 | 243 | ## [v3.1](https://github.com/alichtman/shallow-backup/tree/v3.1) (2019-11-15) 244 | 245 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.8...v3.1) 246 | 247 | **Closed issues:** 248 | 249 | - Revamp tests [\#237](https://github.com/alichtman/shallow-backup/issues/237) 250 | - Conform to XDG spec [\#236](https://github.com/alichtman/shallow-backup/issues/236) 251 | 252 | **Merged pull requests:** 253 | 254 | - Respect XDG Base Directory spec [\#239](https://github.com/alichtman/shallow-backup/pull/239) ([alichtman](https://github.com/alichtman)) 255 | - Fix tests [\#238](https://github.com/alichtman/shallow-backup/pull/238) ([alichtman](https://github.com/alichtman)) 256 | 257 | ## [v2.8](https://github.com/alichtman/shallow-backup/tree/v2.8) (2019-10-16) 258 | 259 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.7...v2.8) 260 | 261 | **Closed issues:** 262 | 263 | - Unable to run macOS [\#235](https://github.com/alichtman/shallow-backup/issues/235) 264 | 265 | ## [v2.7](https://github.com/alichtman/shallow-backup/tree/v2.7) (2019-10-08) 266 | 267 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.6...v2.7) 268 | 269 | **Fixed bugs:** 270 | 271 | - Handle JSON errors in the config [\#233](https://github.com/alichtman/shallow-backup/issues/233) 272 | 273 | **Merged pull requests:** 274 | 275 | - Config syntax error handling [\#234](https://github.com/alichtman/shallow-backup/pull/234) ([alichtman](https://github.com/alichtman)) 276 | 277 | ## [v2.6](https://github.com/alichtman/shallow-backup/tree/v2.6) (2019-09-23) 278 | 279 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.4...v2.6) 280 | 281 | **Fixed bugs:** 282 | 283 | - Can't back up dotfiles that don't live directly inside $HOME [\#230](https://github.com/alichtman/shallow-backup/issues/230) 284 | - Double check git commit logic [\#227](https://github.com/alichtman/shallow-backup/issues/227) 285 | 286 | **Closed issues:** 287 | 288 | - How do you backup minus the shallow-backup repo? [\#225](https://github.com/alichtman/shallow-backup/issues/225) 289 | 290 | **Merged pull requests:** 291 | 292 | - Patch failing commit behavior when submodules are present [\#232](https://github.com/alichtman/shallow-backup/pull/232) ([alichtman](https://github.com/alichtman)) 293 | - Allow backing up dotfiles outside of $HOME [\#231](https://github.com/alichtman/shallow-backup/pull/231) ([alichtman](https://github.com/alichtman)) 294 | 295 | ## [v2.4](https://github.com/alichtman/shallow-backup/tree/v2.4) (2019-05-12) 296 | 297 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.3...v2.4) 298 | 299 | **Fixed bugs:** 300 | 301 | - Back up fonts directory [\#219](https://github.com/alichtman/shallow-backup/issues/219) 302 | 303 | **Closed issues:** 304 | 305 | - When clearing old backup files, delete everything except `.git/` and `.gitignore` [\#223](https://github.com/alichtman/shallow-backup/issues/223) 306 | - You should try asciinema instead of upload big GIF file demo [\#222](https://github.com/alichtman/shallow-backup/issues/222) 307 | - Similar\(ish\) project to be aware of? [\#220](https://github.com/alichtman/shallow-backup/issues/220) 308 | 309 | **Merged pull requests:** 310 | 311 | - Don't delete .git when removing old backups [\#224](https://github.com/alichtman/shallow-backup/pull/224) ([alichtman](https://github.com/alichtman)) 312 | - No such file or directory during fonts backup [\#217](https://github.com/alichtman/shallow-backup/pull/217) ([robbixc](https://github.com/robbixc)) 313 | 314 | ## [v2.3](https://github.com/alichtman/shallow-backup/tree/v2.3) (2019-01-07) 315 | 316 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.2...v2.3) 317 | 318 | **Fixed bugs:** 319 | 320 | - Backup pip3 packages [\#211](https://github.com/alichtman/shallow-backup/issues/211) 321 | 322 | **Closed issues:** 323 | 324 | - Restore VSCode backups [\#213](https://github.com/alichtman/shallow-backup/issues/213) 325 | - Reinstall VSCode backup [\#212](https://github.com/alichtman/shallow-backup/issues/212) 326 | - Exception handling [\#206](https://github.com/alichtman/shallow-backup/issues/206) 327 | - Ruby gems Backup and VSCode [\#204](https://github.com/alichtman/shallow-backup/issues/204) 328 | - Don't prompt for confirmation to delete subdir if all files in the subdir are tracked and unchanged [\#146](https://github.com/alichtman/shallow-backup/issues/146) 329 | - VSCode Backup [\#45](https://github.com/alichtman/shallow-backup/issues/45) 330 | 331 | **Merged pull requests:** 332 | 333 | - Exception handling [\#207](https://github.com/alichtman/shallow-backup/pull/207) ([ibokuri](https://github.com/ibokuri)) 334 | - Added VSCode settings and extensions backup/reinstall, pip3 backup. [\#205](https://github.com/alichtman/shallow-backup/pull/205) ([AlexanderProd](https://github.com/AlexanderProd)) 335 | 336 | ## [v2.2](https://github.com/alichtman/shallow-backup/tree/v2.2) (2018-12-14) 337 | 338 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.1...v2.2) 339 | 340 | **Fixed bugs:** 341 | 342 | - KeyError: 'sublime2' when creating a backup [\#202](https://github.com/alichtman/shallow-backup/issues/202) 343 | - gitpython Not Installed Automatically w/ setup.py [\#200](https://github.com/alichtman/shallow-backup/issues/200) 344 | - Configs need to be a mapping in the config file. [\#195](https://github.com/alichtman/shallow-backup/issues/195) 345 | - Prompt to remove outdated config if detected. [\#189](https://github.com/alichtman/shallow-backup/issues/189) 346 | - Remove this plist special case logic. [\#187](https://github.com/alichtman/shallow-backup/issues/187) 347 | 348 | **Closed issues:** 349 | 350 | - Fix tests that fail due to multiprocessing [\#196](https://github.com/alichtman/shallow-backup/issues/196) 351 | - Test abspath/env expanding function [\#194](https://github.com/alichtman/shallow-backup/issues/194) 352 | - Extract all config section names to a dict in config.py [\#190](https://github.com/alichtman/shallow-backup/issues/190) 353 | - Extract messages to constants file [\#179](https://github.com/alichtman/shallow-backup/issues/179) 354 | - Turn this into a generic copy method [\#177](https://github.com/alichtman/shallow-backup/issues/177) 355 | - Extract package managers to config file [\#165](https://github.com/alichtman/shallow-backup/issues/165) 356 | - Option to add ssh keys when they're reinstalled [\#150](https://github.com/alichtman/shallow-backup/issues/150) 357 | - Selectively back up from .atom folder [\#133](https://github.com/alichtman/shallow-backup/issues/133) 358 | - Separate public and private backups [\#132](https://github.com/alichtman/shallow-backup/issues/132) 359 | - Symlink instead of copying files [\#125](https://github.com/alichtman/shallow-backup/issues/125) 360 | - Automatic archiving after a new backup [\#176](https://github.com/alichtman/shallow-backup/issues/176) 361 | 362 | **Merged pull requests:** 363 | 364 | - Remove Sublime \[2/3\] packages backup [\#203](https://github.com/alichtman/shallow-backup/pull/203) ([alichtman](https://github.com/alichtman)) 365 | - \#200 added gitpython to setup.py [\#201](https://github.com/alichtman/shallow-backup/pull/201) ([AlexanderProd](https://github.com/AlexanderProd)) 366 | 367 | ## [v2.1](https://github.com/alichtman/shallow-backup/tree/v2.1) (2018-11-14) 368 | 369 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v2.0...v2.1) 370 | 371 | **Fixed bugs:** 372 | 373 | - Prompt to remove outdated config if detected. [\#189](https://github.com/alichtman/shallow-backup/issues/189) 374 | - Remove this plist special case logic. [\#187](https://github.com/alichtman/shallow-backup/issues/187) 375 | - Jetbrains IDE backups do not work [\#158](https://github.com/alichtman/shallow-backup/issues/158) 376 | 377 | **Closed issues:** 378 | 379 | - Test Package Reinstallation [\#185](https://github.com/alichtman/shallow-backup/issues/185) 380 | - Update docs for next release [\#96](https://github.com/alichtman/shallow-backup/issues/96) 381 | 382 | **Merged pull requests:** 383 | 384 | - Add test for backups. [\#191](https://github.com/alichtman/shallow-backup/pull/191) ([alichtman](https://github.com/alichtman)) 385 | 386 | ## [v2.0](https://github.com/alichtman/shallow-backup/tree/v2.0) (2018-11-09) 387 | 388 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v1.3...v2.0) 389 | 390 | **Fixed bugs:** 391 | 392 | - Enhance git repo move [\#168](https://github.com/alichtman/shallow-backup/issues/168) 393 | - Accept ~ in backup path name [\#155](https://github.com/alichtman/shallow-backup/issues/155) 394 | - Font Reinstallation doesn't work for some reason [\#145](https://github.com/alichtman/shallow-backup/issues/145) 395 | - Tests won't run [\#142](https://github.com/alichtman/shallow-backup/issues/142) 396 | - Module imports not working properly in refactored code [\#141](https://github.com/alichtman/shallow-backup/issues/141) 397 | - Stop SUBLIME folders from being called `Packages` [\#113](https://github.com/alichtman/shallow-backup/issues/113) 398 | - Backup Fonts doesn't back up all fonts [\#111](https://github.com/alichtman/shallow-backup/issues/111) 399 | - Bug: Terminal.plist not being backed up [\#108](https://github.com/alichtman/shallow-backup/issues/108) 400 | - On backup path update, the .git folder should be moved. [\#97](https://github.com/alichtman/shallow-backup/issues/97) 401 | - Fix shell=True security issues [\#73](https://github.com/alichtman/shallow-backup/issues/73) 402 | - It doesn't backup global npm packages? [\#61](https://github.com/alichtman/shallow-backup/issues/61) 403 | - Error? Or do I just not know how to use this. [\#54](https://github.com/alichtman/shallow-backup/issues/54) 404 | 405 | **Closed issues:** 406 | 407 | - Unformatted error should be formatted and rephrased. [\#175](https://github.com/alichtman/shallow-backup/issues/175) 408 | - Add "public repo" warning to setting remote URL prompt [\#174](https://github.com/alichtman/shallow-backup/issues/174) 409 | - Extend printing utilities to formatted paths [\#172](https://github.com/alichtman/shallow-backup/issues/172) 410 | - Refactor config file [\#166](https://github.com/alichtman/shallow-backup/issues/166) 411 | - Support expanding ENV variables in path inputs [\#164](https://github.com/alichtman/shallow-backup/issues/164) 412 | - --version should print version info [\#162](https://github.com/alichtman/shallow-backup/issues/162) 413 | - Refactor version printing in CLI with Click [\#159](https://github.com/alichtman/shallow-backup/issues/159) 414 | - Remove --add and --rm CLI args. [\#156](https://github.com/alichtman/shallow-backup/issues/156) 415 | - Update config backup path mappings to be `config/...` [\#154](https://github.com/alichtman/shallow-backup/issues/154) 416 | - Use --yes click option for confirmation to delete the backup. [\#152](https://github.com/alichtman/shallow-backup/issues/152) 417 | - Rethink how `add` and `rm` args should work [\#140](https://github.com/alichtman/shallow-backup/issues/140) 418 | - Reorganize project [\#136](https://github.com/alichtman/shallow-backup/issues/136) 419 | - Update setup.py for next release [\#135](https://github.com/alichtman/shallow-backup/issues/135) 420 | - Refactor --rm to take a single path arg [\#130](https://github.com/alichtman/shallow-backup/issues/130) 421 | - Add --add & --rm commands to actions menu [\#129](https://github.com/alichtman/shallow-backup/issues/129) 422 | - Add config paths to config file [\#128](https://github.com/alichtman/shallow-backup/issues/128) 423 | - Speed Optimizations [\#124](https://github.com/alichtman/shallow-backup/issues/124) 424 | - Speed Up Backup Process [\#123](https://github.com/alichtman/shallow-backup/issues/123) 425 | - Extract all hardcoded filepaths to constants/functions [\#116](https://github.com/alichtman/shallow-backup/issues/116) 426 | - ERROR collecting tests [\#115](https://github.com/alichtman/shallow-backup/issues/115) 427 | - Refactor copying methods [\#112](https://github.com/alichtman/shallow-backup/issues/112) 428 | - Make each package manager print in a color that's not the normal log color for reinstallation [\#109](https://github.com/alichtman/shallow-backup/issues/109) 429 | - Linux Compatibility [\#104](https://github.com/alichtman/shallow-backup/issues/104) 430 | - Add styling guide + design guide [\#103](https://github.com/alichtman/shallow-backup/issues/103) 431 | - Set up continuous integration with Travis CI [\#102](https://github.com/alichtman/shallow-backup/issues/102) 432 | - Add -delete\_backup argument to remove backup dir [\#95](https://github.com/alichtman/shallow-backup/issues/95) 433 | - Currently shallow backup writes to directory ./DEFAULT/ if you don't choose a custom directory [\#93](https://github.com/alichtman/shallow-backup/issues/93) 434 | - Running error when built from source? [\#92](https://github.com/alichtman/shallow-backup/issues/92) 435 | - Backup Jetbrains IDE Configs [\#87](https://github.com/alichtman/shallow-backup/issues/87) 436 | - More reinstallation support [\#86](https://github.com/alichtman/shallow-backup/issues/86) 437 | - Add changelog [\#83](https://github.com/alichtman/shallow-backup/issues/83) 438 | - Rename "configs" directory to "app\_configs" [\#82](https://github.com/alichtman/shallow-backup/issues/82) 439 | - Further Git Integration [\#81](https://github.com/alichtman/shallow-backup/issues/81) 440 | - Prompt for remote url in CLI [\#79](https://github.com/alichtman/shallow-backup/issues/79) 441 | - Add config file for dotfiles and directories to back up. [\#76](https://github.com/alichtman/shallow-backup/issues/76) 442 | - Automatically create .gitignore to protect private files [\#71](https://github.com/alichtman/shallow-backup/issues/71) 443 | - Testing Suite [\#69](https://github.com/alichtman/shallow-backup/issues/69) 444 | - Don't delete the .git directory when creating a new backup [\#68](https://github.com/alichtman/shallow-backup/issues/68) 445 | - Extract lists of files/directories that don't change to a constants file [\#67](https://github.com/alichtman/shallow-backup/issues/67) 446 | - Add Pipfile for Pipenv [\#64](https://github.com/alichtman/shallow-backup/issues/64) 447 | - Default install missing ConfigParser dependency? [\#62](https://github.com/alichtman/shallow-backup/issues/62) 448 | - No module named configparser [\#60](https://github.com/alichtman/shallow-backup/issues/60) 449 | - -reinstall should reinstall dotfiles [\#59](https://github.com/alichtman/shallow-backup/issues/59) 450 | - Add -configs backup option [\#58](https://github.com/alichtman/shallow-backup/issues/58) 451 | - Could shallow-backup integrate with git for backup? [\#57](https://github.com/alichtman/shallow-backup/issues/57) 452 | - README [\#56](https://github.com/alichtman/shallow-backup/issues/56) 453 | - Make shallow-backup compatible with Python 2.7 [\#55](https://github.com/alichtman/shallow-backup/issues/55) 454 | - Remove XCode Backup [\#53](https://github.com/alichtman/shallow-backup/issues/53) 455 | - Backup Atom Config [\#49](https://github.com/alichtman/shallow-backup/issues/49) 456 | - Backup Terminal Preferences from .plist file [\#48](https://github.com/alichtman/shallow-backup/issues/48) 457 | - Add option for backing up specific filepaths. [\#22](https://github.com/alichtman/shallow-backup/issues/22) 458 | - Add GUI [\#10](https://github.com/alichtman/shallow-backup/issues/10) 459 | - Homebrew Release [\#6](https://github.com/alichtman/shallow-backup/issues/6) 460 | - Update docs for next release [\#96](https://github.com/alichtman/shallow-backup/issues/96) 461 | 462 | **Merged pull requests:** 463 | 464 | - Linux compatibility [\#183](https://github.com/alichtman/shallow-backup/pull/183) ([alichtman](https://github.com/alichtman)) 465 | - Added public repo warning [\#182](https://github.com/alichtman/shallow-backup/pull/182) ([alichtman](https://github.com/alichtman)) 466 | - Config file refactor [\#181](https://github.com/alichtman/shallow-backup/pull/181) ([alichtman](https://github.com/alichtman)) 467 | - Refactor config file architecture. [\#180](https://github.com/alichtman/shallow-backup/pull/180) ([alichtman](https://github.com/alichtman)) 468 | - New print\_color\_bold\_path helper methods [\#173](https://github.com/alichtman/shallow-backup/pull/173) ([nunomdc](https://github.com/nunomdc)) 469 | - Exit if a git repository exists on the new backup path [\#171](https://github.com/alichtman/shallow-backup/pull/171) ([nunomdc](https://github.com/nunomdc)) 470 | - Added long option for version output [\#170](https://github.com/alichtman/shallow-backup/pull/170) ([nunomdc](https://github.com/nunomdc)) 471 | - Expand environment variables [\#169](https://github.com/alichtman/shallow-backup/pull/169) ([nunomdc](https://github.com/nunomdc)) 472 | - Expand ~ as user's home directory [\#161](https://github.com/alichtman/shallow-backup/pull/161) ([nunomdc](https://github.com/nunomdc)) 473 | - Add --add and --rm path to Action Menu [\#157](https://github.com/alichtman/shallow-backup/pull/157) ([alichtman](https://github.com/alichtman)) 474 | - Prettify CLI help menu [\#153](https://github.com/alichtman/shallow-backup/pull/153) ([alichtman](https://github.com/alichtman)) 475 | - Fix font reinstallation [\#151](https://github.com/alichtman/shallow-backup/pull/151) ([alichtman](https://github.com/alichtman)) 476 | - Pkg mgr printing [\#149](https://github.com/alichtman/shallow-backup/pull/149) ([alichtman](https://github.com/alichtman)) 477 | - Better reinstallation options and better scripting support [\#147](https://github.com/alichtman/shallow-backup/pull/147) ([alichtman](https://github.com/alichtman)) 478 | - Fix travis [\#143](https://github.com/alichtman/shallow-backup/pull/143) ([alichtman](https://github.com/alichtman)) 479 | - Refactoring and Reorganization [\#139](https://github.com/alichtman/shallow-backup/pull/139) ([alichtman](https://github.com/alichtman)) 480 | - Readme [\#134](https://github.com/alichtman/shallow-backup/pull/134) ([alichtman](https://github.com/alichtman)) 481 | - Improved git integration. Added remote URL prompt. [\#131](https://github.com/alichtman/shallow-backup/pull/131) ([alichtman](https://github.com/alichtman)) 482 | - --add, --rm and -show args for config interaction [\#126](https://github.com/alichtman/shallow-backup/pull/126) ([alichtman](https://github.com/alichtman)) 483 | - fix bug: path was absolute so os.path.join was discarding the user ho… [\#120](https://github.com/alichtman/shallow-backup/pull/120) ([giancarloGiuffra](https://github.com/giancarloGiuffra)) 484 | - Clean up [\#119](https://github.com/alichtman/shallow-backup/pull/119) ([alichtman](https://github.com/alichtman)) 485 | - Extracted logo to constants.py [\#118](https://github.com/alichtman/shallow-backup/pull/118) ([alichtman](https://github.com/alichtman)) 486 | - Security update and refactoring [\#114](https://github.com/alichtman/shallow-backup/pull/114) ([alichtman](https://github.com/alichtman)) 487 | - Travis test fixes? [\#107](https://github.com/alichtman/shallow-backup/pull/107) ([alichtman](https://github.com/alichtman)) 488 | - Travis test [\#106](https://github.com/alichtman/shallow-backup/pull/106) ([alichtman](https://github.com/alichtman)) 489 | - Fix git move tests [\#105](https://github.com/alichtman/shallow-backup/pull/105) ([alichtman](https://github.com/alichtman)) 490 | - Add remove backup dir functionality [\#101](https://github.com/alichtman/shallow-backup/pull/101) ([neequole](https://github.com/neequole)) 491 | - Added support for JetBrains IDEs [\#100](https://github.com/alichtman/shallow-backup/pull/100) ([Brand-Temp](https://github.com/Brand-Temp)) 492 | - Move git folder on path change [\#99](https://github.com/alichtman/shallow-backup/pull/99) ([pyasi](https://github.com/pyasi)) 493 | - Pulls changes from remote before pushing. [\#98](https://github.com/alichtman/shallow-backup/pull/98) ([alichtman](https://github.com/alichtman)) 494 | - Push to remote URL and git logging added [\#91](https://github.com/alichtman/shallow-backup/pull/91) ([alichtman](https://github.com/alichtman)) 495 | - Refactored config backup and added more user output [\#90](https://github.com/alichtman/shallow-backup/pull/90) ([alichtman](https://github.com/alichtman)) 496 | - Add tests for copying. Setup project for pytest. [\#88](https://github.com/alichtman/shallow-backup/pull/88) ([pyasi](https://github.com/pyasi)) 497 | - Added changelog [\#84](https://github.com/alichtman/shallow-backup/pull/84) ([alichtman](https://github.com/alichtman)) 498 | - Git integration for shallow-backup [\#78](https://github.com/alichtman/shallow-backup/pull/78) ([alichtman](https://github.com/alichtman)) 499 | - Revert "Autoformat all Python in repo with `autopep8`" [\#75](https://github.com/alichtman/shallow-backup/pull/75) ([alichtman](https://github.com/alichtman)) 500 | - Make npm backup global packages only [\#74](https://github.com/alichtman/shallow-backup/pull/74) ([jasikpark](https://github.com/jasikpark)) 501 | - Autoformat all Python in repo with `autopep8` [\#70](https://github.com/alichtman/shallow-backup/pull/70) ([jasikpark](https://github.com/jasikpark)) 502 | - Added Pipfile [\#66](https://github.com/alichtman/shallow-backup/pull/66) ([rmad17](https://github.com/rmad17)) 503 | - Add -configs mode. [\#63](https://github.com/alichtman/shallow-backup/pull/63) ([schilli91](https://github.com/schilli91)) 504 | 505 | ## [v1.3](https://github.com/alichtman/shallow-backup/tree/v1.3) (2018-05-30) 506 | 507 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v1.2...v1.3) 508 | 509 | ## [v1.2](https://github.com/alichtman/shallow-backup/tree/v1.2) (2018-05-30) 510 | 511 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v1.0...v1.2) 512 | 513 | **Fixed bugs:** 514 | 515 | - Don't store embedded git repos [\#44](https://github.com/alichtman/shallow-backup/issues/44) 516 | - Check if package manager installed before creating backup [\#41](https://github.com/alichtman/shallow-backup/issues/41) 517 | - Running with sudo causes this error for homebrew and pip [\#39](https://github.com/alichtman/shallow-backup/issues/39) 518 | 519 | **Closed issues:** 520 | 521 | - Cargo backup list [\#46](https://github.com/alichtman/shallow-backup/issues/46) 522 | - Don't back up .pyc files [\#43](https://github.com/alichtman/shallow-backup/issues/43) 523 | - GUI [\#42](https://github.com/alichtman/shallow-backup/issues/42) 524 | - Fix Permissions Error on Preferences [\#40](https://github.com/alichtman/shallow-backup/issues/40) 525 | - dev\_dots option to only backup dev-related dotfiles [\#33](https://github.com/alichtman/shallow-backup/issues/33) 526 | - Don't copy atom packages [\#32](https://github.com/alichtman/shallow-backup/issues/32) 527 | - Upgrade from cp to rsync for dotfiles [\#31](https://github.com/alichtman/shallow-backup/issues/31) 528 | - Automatic backup to restic [\#26](https://github.com/alichtman/shallow-backup/issues/26) 529 | - Submit to same lists as stronghold [\#14](https://github.com/alichtman/shallow-backup/issues/14) 530 | 531 | **Merged pull requests:** 532 | 533 | - Fix dotfolder bug [\#52](https://github.com/alichtman/shallow-backup/pull/52) ([alichtman](https://github.com/alichtman)) 534 | - Add cargo backup [\#51](https://github.com/alichtman/shallow-backup/pull/51) ([alichtman](https://github.com/alichtman)) 535 | - Clean up empty package list files [\#50](https://github.com/alichtman/shallow-backup/pull/50) ([alichtman](https://github.com/alichtman)) 536 | 537 | ## [v1.0](https://github.com/alichtman/shallow-backup/tree/v1.0) (2018-05-14) 538 | 539 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/v0.4...v1.0) 540 | 541 | **Closed issues:** 542 | 543 | - Add reinstall option [\#37](https://github.com/alichtman/shallow-backup/issues/37) 544 | - Backup Sublime Settings [\#36](https://github.com/alichtman/shallow-backup/issues/36) 545 | - Add System Preferences [\#35](https://github.com/alichtman/shallow-backup/issues/35) 546 | - Add XCode UserData [\#34](https://github.com/alichtman/shallow-backup/issues/34) 547 | - Application Preferences and Config Files [\#30](https://github.com/alichtman/shallow-backup/issues/30) 548 | - Backup Browser Prefs [\#29](https://github.com/alichtman/shallow-backup/issues/29) 549 | - "The Idea" -\> "Inspiration" [\#28](https://github.com/alichtman/shallow-backup/issues/28) 550 | - Add option to encrypt decrypt git repository [\#27](https://github.com/alichtman/shallow-backup/issues/27) 551 | - backup native app prefs [\#25](https://github.com/alichtman/shallow-backup/issues/25) 552 | - backup chrome prefs [\#24](https://github.com/alichtman/shallow-backup/issues/24) 553 | - backup firefox prefs [\#23](https://github.com/alichtman/shallow-backup/issues/23) 554 | - Backup all files that begin with dot, like literally all dotfiles [\#21](https://github.com/alichtman/shallow-backup/issues/21) 555 | - Add jetbrains config files [\#20](https://github.com/alichtman/shallow-backup/issues/20) 556 | - Add Atom packages list [\#19](https://github.com/alichtman/shallow-backup/issues/19) 557 | - Add sublime text packages list [\#18](https://github.com/alichtman/shallow-backup/issues/18) 558 | - Coverage for other package managers [\#2](https://github.com/alichtman/shallow-backup/issues/2) 559 | 560 | ## [v0.4](https://github.com/alichtman/shallow-backup/tree/v0.4) (2018-04-14) 561 | 562 | [Full Changelog](https://github.com/alichtman/shallow-backup/compare/7c53c198f405828e6f1f0c4edf477b209b840fab...v0.4) 563 | 564 | **Closed issues:** 565 | 566 | - uninstall option [\#17](https://github.com/alichtman/shallow-backup/issues/17) 567 | - Copy font .otf and .ttf files, not just a list of names [\#16](https://github.com/alichtman/shallow-backup/issues/16) 568 | - figure out how to set pypi name to shallow-backup [\#15](https://github.com/alichtman/shallow-backup/issues/15) 569 | - README Updates [\#13](https://github.com/alichtman/shallow-backup/issues/13) 570 | - Protection for text\_backup dir [\#12](https://github.com/alichtman/shallow-backup/issues/12) 571 | - PyPi [\#11](https://github.com/alichtman/shallow-backup/issues/11) 572 | - setup.py descriptors [\#9](https://github.com/alichtman/shallow-backup/issues/9) 573 | - Add -reinstall cli option [\#8](https://github.com/alichtman/shallow-backup/issues/8) 574 | - Add CLI [\#7](https://github.com/alichtman/shallow-backup/issues/7) 575 | - README [\#5](https://github.com/alichtman/shallow-backup/issues/5) 576 | - Not backing up all fonts [\#4](https://github.com/alichtman/shallow-backup/issues/4) 577 | - better name pls [\#3](https://github.com/alichtman/shallow-backup/issues/3) 578 | - Better README [\#1](https://github.com/alichtman/shallow-backup/issues/1) 579 | 580 | 581 | 582 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 583 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Thank you for contributing! 2 | 3 | **Please follow these steps to get your work merged in.** 4 | 5 | 0. Read the design guidelines in the `docs/` folder. 6 | 1. Clone the repo and make a new branch: `$ git checkout https://github.com/alichtman/shallow-backup -b [name_of_new_branch]`. 7 | 2. Add a feature, fix a bug, or refactor some code :) 8 | 3. Write/update tests for the changes you made, if necessary. 9 | 3. Run unit tests and make sure all tests pass: `python3 -m pytest`. 10 | 4. Update `README.md` and `CONTRIBUTORS.md`, if necessary. 11 | 5. Open a Pull Request with a comprehensive description of changes. 12 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | - [Caleb Jasik](https://github.com/jasikpark) 4 | - [Moritz Schillinger](https://github.com/schilli91) 5 | - [Brandon Temple](https://github.com/Brand-Temp) 6 | - [Peter Yasi](https://github.com/pyasi) 7 | - [Nicole Tibay](https://github.com/neequole) 8 | - [Rods](https://github.com/rods-honorio) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Aaron Lichtman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | click = ">=7.0" 8 | colorama = ">=0.4.3" 9 | gitpython = ">=3.1.32" 10 | inquirer = ">=2.6.3" 11 | black = "*" 12 | better-exceptions = "*" 13 | 14 | [dev-packages] 15 | pytest = ">=5.4.1" 16 | pytest-cov = ">=2.8.1" 17 | twine = "*" 18 | black = "*" 19 | setuptools = "*" 20 | 21 | [requires] 22 | python_version = "3.12" 23 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "1fcf5614a861e56de7a8f6f73c9781af121850ed34dff3d0f7f7a70e05621014" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.12" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "better-exceptions": { 20 | "hashes": [ 21 | "sha256:9c70b1c61d5a179b84cd2c9d62c3324b667d74286207343645ed4306fdaad976", 22 | "sha256:bf111d0c9994ac1123f29c24907362bed2320a86809c85f0d858396000667ce2", 23 | "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b" 24 | ], 25 | "index": "pypi", 26 | "version": "==0.3.3" 27 | }, 28 | "black": { 29 | "hashes": [ 30 | "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474", 31 | "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1", 32 | "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0", 33 | "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8", 34 | "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96", 35 | "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1", 36 | "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04", 37 | "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021", 38 | "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94", 39 | "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d", 40 | "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c", 41 | "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7", 42 | "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c", 43 | "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc", 44 | "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7", 45 | "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d", 46 | "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c", 47 | "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741", 48 | "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce", 49 | "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb", 50 | "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063", 51 | "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e" 52 | ], 53 | "index": "pypi", 54 | "markers": "python_version >= '3.8'", 55 | "version": "==24.4.2" 56 | }, 57 | "blessed": { 58 | "hashes": [ 59 | "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058", 60 | "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680" 61 | ], 62 | "markers": "python_version >= '2.7'", 63 | "version": "==1.20.0" 64 | }, 65 | "click": { 66 | "hashes": [ 67 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 68 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 69 | ], 70 | "index": "pypi", 71 | "markers": "python_version >= '3.7'", 72 | "version": "==8.1.7" 73 | }, 74 | "colorama": { 75 | "hashes": [ 76 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 77 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 78 | ], 79 | "index": "pypi", 80 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 81 | "version": "==0.4.6" 82 | }, 83 | "editor": { 84 | "hashes": [ 85 | "sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8", 86 | "sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf" 87 | ], 88 | "markers": "python_version >= '3.8'", 89 | "version": "==1.6.6" 90 | }, 91 | "gitdb": { 92 | "hashes": [ 93 | "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", 94 | "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" 95 | ], 96 | "markers": "python_version >= '3.7'", 97 | "version": "==4.0.11" 98 | }, 99 | "gitpython": { 100 | "hashes": [ 101 | "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c", 102 | "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff" 103 | ], 104 | "index": "pypi", 105 | "markers": "python_version >= '3.7'", 106 | "version": "==3.1.43" 107 | }, 108 | "inquirer": { 109 | "hashes": [ 110 | "sha256:2722cec4460b289aab21fc35a3b03c932780ff4e8004163955a8215e20cfd35e", 111 | "sha256:c4be527e8c4e7a1b2c909aa064ef6f1a4466be42224290f21f07f6d5947171f4" 112 | ], 113 | "index": "pypi", 114 | "markers": "python_full_version >= '3.8.1'", 115 | "version": "==3.3.0" 116 | }, 117 | "mypy-extensions": { 118 | "hashes": [ 119 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 120 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 121 | ], 122 | "markers": "python_version >= '3.5'", 123 | "version": "==1.0.0" 124 | }, 125 | "packaging": { 126 | "hashes": [ 127 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 128 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 129 | ], 130 | "markers": "python_version >= '3.8'", 131 | "version": "==24.1" 132 | }, 133 | "pathspec": { 134 | "hashes": [ 135 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 136 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 137 | ], 138 | "markers": "python_version >= '3.8'", 139 | "version": "==0.12.1" 140 | }, 141 | "platformdirs": { 142 | "hashes": [ 143 | "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", 144 | "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" 145 | ], 146 | "markers": "python_version >= '3.8'", 147 | "version": "==4.2.2" 148 | }, 149 | "readchar": { 150 | "hashes": [ 151 | "sha256:6f44d1b5f0fd93bd93236eac7da39609f15df647ab9cea39f5bc7478b3344b99", 152 | "sha256:d163680656b34f263fb5074023db44b999c68ff31ab394445ebfd1a2a41fe9a2" 153 | ], 154 | "markers": "python_version >= '3.8'", 155 | "version": "==4.1.0" 156 | }, 157 | "runs": { 158 | "hashes": [ 159 | "sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd", 160 | "sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1" 161 | ], 162 | "markers": "python_version >= '3.8'", 163 | "version": "==1.2.2" 164 | }, 165 | "six": { 166 | "hashes": [ 167 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 168 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 169 | ], 170 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 171 | "version": "==1.16.0" 172 | }, 173 | "smmap": { 174 | "hashes": [ 175 | "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", 176 | "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" 177 | ], 178 | "markers": "python_version >= '3.7'", 179 | "version": "==5.0.1" 180 | }, 181 | "wcwidth": { 182 | "hashes": [ 183 | "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", 184 | "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" 185 | ], 186 | "version": "==0.2.13" 187 | }, 188 | "xmod": { 189 | "hashes": [ 190 | "sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377", 191 | "sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48" 192 | ], 193 | "markers": "python_version >= '3.8'", 194 | "version": "==1.8.1" 195 | } 196 | }, 197 | "develop": { 198 | "black": { 199 | "hashes": [ 200 | "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474", 201 | "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1", 202 | "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0", 203 | "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8", 204 | "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96", 205 | "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1", 206 | "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04", 207 | "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021", 208 | "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94", 209 | "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d", 210 | "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c", 211 | "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7", 212 | "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c", 213 | "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc", 214 | "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7", 215 | "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d", 216 | "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c", 217 | "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741", 218 | "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce", 219 | "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb", 220 | "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063", 221 | "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e" 222 | ], 223 | "index": "pypi", 224 | "markers": "python_version >= '3.8'", 225 | "version": "==24.4.2" 226 | }, 227 | "certifi": { 228 | "hashes": [ 229 | "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", 230 | "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" 231 | ], 232 | "markers": "python_version >= '3.6'", 233 | "version": "==2024.7.4" 234 | }, 235 | "cffi": { 236 | "hashes": [ 237 | "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", 238 | "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", 239 | "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", 240 | "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", 241 | "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", 242 | "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", 243 | "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", 244 | "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", 245 | "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", 246 | "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", 247 | "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", 248 | "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", 249 | "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", 250 | "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", 251 | "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", 252 | "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", 253 | "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", 254 | "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", 255 | "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", 256 | "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", 257 | "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", 258 | "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", 259 | "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", 260 | "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", 261 | "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", 262 | "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", 263 | "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", 264 | "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", 265 | "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", 266 | "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", 267 | "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", 268 | "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", 269 | "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", 270 | "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", 271 | "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", 272 | "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", 273 | "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", 274 | "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", 275 | "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", 276 | "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", 277 | "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", 278 | "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", 279 | "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", 280 | "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", 281 | "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", 282 | "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", 283 | "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", 284 | "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", 285 | "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", 286 | "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", 287 | "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", 288 | "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" 289 | ], 290 | "markers": "platform_python_implementation != 'PyPy'", 291 | "version": "==1.16.0" 292 | }, 293 | "charset-normalizer": { 294 | "hashes": [ 295 | "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", 296 | "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", 297 | "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", 298 | "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", 299 | "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", 300 | "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", 301 | "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", 302 | "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", 303 | "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", 304 | "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", 305 | "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", 306 | "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", 307 | "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", 308 | "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", 309 | "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", 310 | "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", 311 | "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", 312 | "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", 313 | "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", 314 | "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", 315 | "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", 316 | "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", 317 | "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", 318 | "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", 319 | "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", 320 | "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", 321 | "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", 322 | "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", 323 | "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", 324 | "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", 325 | "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", 326 | "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", 327 | "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", 328 | "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", 329 | "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", 330 | "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", 331 | "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", 332 | "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", 333 | "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", 334 | "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", 335 | "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", 336 | "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", 337 | "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", 338 | "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", 339 | "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", 340 | "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", 341 | "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", 342 | "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", 343 | "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", 344 | "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", 345 | "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", 346 | "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", 347 | "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", 348 | "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", 349 | "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", 350 | "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", 351 | "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", 352 | "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", 353 | "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", 354 | "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", 355 | "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", 356 | "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", 357 | "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", 358 | "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", 359 | "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", 360 | "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", 361 | "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", 362 | "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", 363 | "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", 364 | "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", 365 | "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", 366 | "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", 367 | "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", 368 | "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", 369 | "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", 370 | "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", 371 | "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", 372 | "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", 373 | "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", 374 | "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", 375 | "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", 376 | "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", 377 | "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", 378 | "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", 379 | "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", 380 | "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", 381 | "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", 382 | "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", 383 | "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", 384 | "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" 385 | ], 386 | "markers": "python_full_version >= '3.7.0'", 387 | "version": "==3.3.2" 388 | }, 389 | "click": { 390 | "hashes": [ 391 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 392 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 393 | ], 394 | "index": "pypi", 395 | "markers": "python_version >= '3.7'", 396 | "version": "==8.1.7" 397 | }, 398 | "coverage": { 399 | "extras": [ 400 | "toml" 401 | ], 402 | "hashes": [ 403 | "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382", 404 | "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1", 405 | "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac", 406 | "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee", 407 | "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166", 408 | "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57", 409 | "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c", 410 | "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b", 411 | "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51", 412 | "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da", 413 | "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450", 414 | "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2", 415 | "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd", 416 | "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d", 417 | "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d", 418 | "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6", 419 | "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca", 420 | "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169", 421 | "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1", 422 | "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713", 423 | "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b", 424 | "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6", 425 | "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c", 426 | "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605", 427 | "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463", 428 | "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b", 429 | "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6", 430 | "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5", 431 | "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63", 432 | "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c", 433 | "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783", 434 | "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44", 435 | "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca", 436 | "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8", 437 | "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d", 438 | "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390", 439 | "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933", 440 | "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67", 441 | "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b", 442 | "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03", 443 | "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b", 444 | "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791", 445 | "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb", 446 | "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807", 447 | "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6", 448 | "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2", 449 | "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428", 450 | "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd", 451 | "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c", 452 | "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94", 453 | "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8", 454 | "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b" 455 | ], 456 | "markers": "python_version >= '3.8'", 457 | "version": "==7.6.0" 458 | }, 459 | "cryptography": { 460 | "hashes": [ 461 | "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad", 462 | "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583", 463 | "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b", 464 | "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", 465 | "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1", 466 | "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648", 467 | "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949", 468 | "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba", 469 | "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c", 470 | "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9", 471 | "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d", 472 | "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c", 473 | "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e", 474 | "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", 475 | "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d", 476 | "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7", 477 | "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70", 478 | "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2", 479 | "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7", 480 | "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", 481 | "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe", 482 | "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e", 483 | "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71", 484 | "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961", 485 | "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7", 486 | "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c", 487 | "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28", 488 | "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842", 489 | "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902", 490 | "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801", 491 | "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", 492 | "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e" 493 | ], 494 | "markers": "python_version >= '3.7'", 495 | "version": "==42.0.8" 496 | }, 497 | "docutils": { 498 | "hashes": [ 499 | "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", 500 | "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2" 501 | ], 502 | "markers": "python_version >= '3.9'", 503 | "version": "==0.21.2" 504 | }, 505 | "idna": { 506 | "hashes": [ 507 | "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", 508 | "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" 509 | ], 510 | "markers": "python_version >= '3.5'", 511 | "version": "==3.7" 512 | }, 513 | "importlib-metadata": { 514 | "hashes": [ 515 | "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f", 516 | "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812" 517 | ], 518 | "markers": "python_version >= '3.8'", 519 | "version": "==8.0.0" 520 | }, 521 | "iniconfig": { 522 | "hashes": [ 523 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 524 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 525 | ], 526 | "markers": "python_version >= '3.7'", 527 | "version": "==2.0.0" 528 | }, 529 | "jaraco.classes": { 530 | "hashes": [ 531 | "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", 532 | "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" 533 | ], 534 | "markers": "python_version >= '3.8'", 535 | "version": "==3.4.0" 536 | }, 537 | "jaraco.context": { 538 | "hashes": [ 539 | "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266", 540 | "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2" 541 | ], 542 | "markers": "python_version >= '3.8'", 543 | "version": "==5.3.0" 544 | }, 545 | "jaraco.functools": { 546 | "hashes": [ 547 | "sha256:3b24ccb921d6b593bdceb56ce14799204f473976e2a9d4b15b04d0f2c2326664", 548 | "sha256:d33fa765374c0611b52f8b3a795f8900869aa88c84769d4d1746cd68fb28c3e8" 549 | ], 550 | "markers": "python_version >= '3.8'", 551 | "version": "==4.0.1" 552 | }, 553 | "jeepney": { 554 | "hashes": [ 555 | "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", 556 | "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" 557 | ], 558 | "markers": "sys_platform == 'linux'", 559 | "version": "==0.8.0" 560 | }, 561 | "keyring": { 562 | "hashes": [ 563 | "sha256:2458681cdefc0dbc0b7eb6cf75d0b98e59f9ad9b2d4edd319d18f68bdca95e50", 564 | "sha256:daaffd42dbda25ddafb1ad5fec4024e5bbcfe424597ca1ca452b299861e49f1b" 565 | ], 566 | "markers": "python_version >= '3.8'", 567 | "version": "==25.2.1" 568 | }, 569 | "markdown-it-py": { 570 | "hashes": [ 571 | "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", 572 | "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" 573 | ], 574 | "markers": "python_version >= '3.8'", 575 | "version": "==3.0.0" 576 | }, 577 | "mdurl": { 578 | "hashes": [ 579 | "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 580 | "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" 581 | ], 582 | "markers": "python_version >= '3.7'", 583 | "version": "==0.1.2" 584 | }, 585 | "more-itertools": { 586 | "hashes": [ 587 | "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463", 588 | "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320" 589 | ], 590 | "markers": "python_version >= '3.8'", 591 | "version": "==10.3.0" 592 | }, 593 | "mypy-extensions": { 594 | "hashes": [ 595 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 596 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 597 | ], 598 | "markers": "python_version >= '3.5'", 599 | "version": "==1.0.0" 600 | }, 601 | "nh3": { 602 | "hashes": [ 603 | "sha256:0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164", 604 | "sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86", 605 | "sha256:19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b", 606 | "sha256:34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad", 607 | "sha256:36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204", 608 | "sha256:3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a", 609 | "sha256:42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200", 610 | "sha256:5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189", 611 | "sha256:6955369e4d9f48f41e3f238a9e60f9410645db7e07435e62c6a9ea6135a4907f", 612 | "sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811", 613 | "sha256:8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844", 614 | "sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4", 615 | "sha256:a7f1b5b2c15866f2db413a3649a8fe4fd7b428ae58be2c0f6bca5eefd53ca2be", 616 | "sha256:c8b3a1cebcba9b3669ed1a84cc65bf005728d2f0bc1ed2a6594a992e817f3a50", 617 | "sha256:de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307", 618 | "sha256:f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe" 619 | ], 620 | "version": "==0.2.18" 621 | }, 622 | "packaging": { 623 | "hashes": [ 624 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 625 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 626 | ], 627 | "markers": "python_version >= '3.8'", 628 | "version": "==24.1" 629 | }, 630 | "pathspec": { 631 | "hashes": [ 632 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 633 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 634 | ], 635 | "markers": "python_version >= '3.8'", 636 | "version": "==0.12.1" 637 | }, 638 | "pkginfo": { 639 | "hashes": [ 640 | "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", 641 | "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097" 642 | ], 643 | "markers": "python_version >= '3.6'", 644 | "version": "==1.10.0" 645 | }, 646 | "platformdirs": { 647 | "hashes": [ 648 | "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", 649 | "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" 650 | ], 651 | "markers": "python_version >= '3.8'", 652 | "version": "==4.2.2" 653 | }, 654 | "pluggy": { 655 | "hashes": [ 656 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 657 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 658 | ], 659 | "markers": "python_version >= '3.8'", 660 | "version": "==1.5.0" 661 | }, 662 | "pycparser": { 663 | "hashes": [ 664 | "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", 665 | "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" 666 | ], 667 | "markers": "python_version >= '3.8'", 668 | "version": "==2.22" 669 | }, 670 | "pygments": { 671 | "hashes": [ 672 | "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", 673 | "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" 674 | ], 675 | "markers": "python_version >= '3.8'", 676 | "version": "==2.18.0" 677 | }, 678 | "pytest": { 679 | "hashes": [ 680 | "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343", 681 | "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977" 682 | ], 683 | "index": "pypi", 684 | "markers": "python_version >= '3.8'", 685 | "version": "==8.2.2" 686 | }, 687 | "pytest-cov": { 688 | "hashes": [ 689 | "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", 690 | "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857" 691 | ], 692 | "index": "pypi", 693 | "markers": "python_version >= '3.8'", 694 | "version": "==5.0.0" 695 | }, 696 | "readme-renderer": { 697 | "hashes": [ 698 | "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", 699 | "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1" 700 | ], 701 | "markers": "python_version >= '3.9'", 702 | "version": "==44.0" 703 | }, 704 | "requests": { 705 | "hashes": [ 706 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 707 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 708 | ], 709 | "markers": "python_version >= '3.8'", 710 | "version": "==2.32.3" 711 | }, 712 | "requests-toolbelt": { 713 | "hashes": [ 714 | "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", 715 | "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" 716 | ], 717 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 718 | "version": "==1.0.0" 719 | }, 720 | "rfc3986": { 721 | "hashes": [ 722 | "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", 723 | "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" 724 | ], 725 | "markers": "python_version >= '3.7'", 726 | "version": "==2.0.0" 727 | }, 728 | "rich": { 729 | "hashes": [ 730 | "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", 731 | "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" 732 | ], 733 | "markers": "python_full_version >= '3.7.0'", 734 | "version": "==13.7.1" 735 | }, 736 | "secretstorage": { 737 | "hashes": [ 738 | "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", 739 | "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" 740 | ], 741 | "markers": "sys_platform == 'linux'", 742 | "version": "==3.3.3" 743 | }, 744 | "setuptools": { 745 | "hashes": [ 746 | "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5", 747 | "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc" 748 | ], 749 | "index": "pypi", 750 | "markers": "python_version >= '3.8'", 751 | "version": "==70.3.0" 752 | }, 753 | "twine": { 754 | "hashes": [ 755 | "sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997", 756 | "sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db" 757 | ], 758 | "index": "pypi", 759 | "markers": "python_version >= '3.8'", 760 | "version": "==5.1.1" 761 | }, 762 | "urllib3": { 763 | "hashes": [ 764 | "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", 765 | "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" 766 | ], 767 | "markers": "python_version >= '3.8'", 768 | "version": "==2.2.2" 769 | }, 770 | "zipp": { 771 | "hashes": [ 772 | "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19", 773 | "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c" 774 | ], 775 | "markers": "python_version >= '3.8'", 776 | "version": "==3.19.2" 777 | } 778 | } 779 | } 780 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shallow-backup 2 | 3 | [![Downloads](http://pepy.tech/badge/shallow-backup)](http://pepy.tech/count/shallow-backup) 4 | [![Build Status](https://travis-ci.com/alichtman/shallow-backup.svg?branch=master)](https://travis-ci.com/alichtman/shallow-backup) 5 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/1719da4d7df5455d8dbb4340c428f851)](https://www.codacy.com/app/alichtman/shallow-backup?utm_source=github.com&utm_medium=referral&utm_content=alichtman/shallow-backup&utm_campaign=Badge_Grade) 6 | 7 | 8 | 9 | `shallow-backup` lets you easily create lightweight backups of installed packages, applications, fonts and dotfiles, and automatically push them to a remote Git repository. 10 | 11 | I use it to manage [my dotfiles](https://github.com/alichtman/dotfiles). 12 | 13 | ![Shallow Backup GIF Demo](img/shallow-backup-demo.gif) 14 | 15 | # Contents 16 | 17 | - [Why?](#why) 18 | - [Installation](#installation) 19 | - [Method 1: pipx](#method-1-pipx) 20 | - [Method 2: Install From Source](#method-2-install-from-source) 21 | - [Dependencies](#dependencies) 22 | - [Usage](#usage) 23 | - [Recipes](#recipes) 24 | - [Maintain a separate repo for your dotfiles](#maintain-a-separate-repo-for-your-dotfiles) 25 | - [Synchronize dotfiles on multiple computers](#synchronize-dotfiles-on-multiple-computers) 26 | - [Reinstall dotfiles from a backup](#reinstall-dotfiles-from-a-backup) 27 | - [What can I back up?](#what-can-i-back-up) 28 | - [Configuration](#configuration) 29 | - [Conditional Backup and Reinstallation](#conditional-backup-and-reinstallation) 30 | - [Git Integration](#git-integration) 31 | - [A Word of Caution](#a-word-of-caution) 32 | - [.gitignore](#gitignore) 33 | - [Output Structure](#output-structure) 34 | - [Want to Contribute?](#want-to-contribute) 35 | 36 | ### Why? 37 | 38 | I wanted a tool that allows you to: 39 | 40 | - Back up dotfiles _from where they live on the system_. 41 | - Back up files from _any_ path on the system, not just `$HOME`. 42 | - Reinstall them from the backup directory idempotently. 43 | - Backup and reinstall files conditionally, so you can easily manage dotfiles across multiple systems. 44 | - Copy files on installation and backup, as opposed to symlinking them. 45 | - Backup package installations in a highly compressed manner. 46 | - Not worry about accidentally doing something dangerous / destructive (is user-protective). 47 | 48 | `shallow-backup` checks all of those boxes. 49 | 50 | ### Installation 51 | 52 | --- 53 | 54 | > **Warning** 55 | > Be careful running this with elevated privileges. Code execution can be achieved with write permissions on the config file. 56 | 57 | #### Method 1: [`pipx`](https://pypi.org/project/shallow-backup/) 58 | 59 | ```bash 60 | $ pipx install shallow-backup 61 | ``` 62 | 63 | #### Method 2: Install From Source 64 | 65 | ```bash 66 | $ git clone https://www.github.com/alichtman/shallow-backup.git 67 | $ cd shallow-backup 68 | $ pip3 install . 69 | ``` 70 | 71 | ### Dependencies 72 | 73 | --- 74 | 75 | - `pre-commit` 76 | - `trufflehog` 77 | 78 | If you are missing the dependencies, you will be guided to install them. 79 | 80 | ### Usage 81 | 82 | --- 83 | 84 | - To start the interactive program, run `$ shallow-backup`. 85 | - To backup your dotfiles, run `$ shallow-backup --backup-dots`. 86 | 87 | `shallow-backup` was built with scripting in mind. Every feature that's supported in the interactive program is supported with command line arguments. 88 | 89 | ```shell 90 | Usage: shallow-backup [OPTIONS] 91 | 92 | Easily back up installed packages, dotfiles, and more. 93 | You can edit which files are backed up in ~/.shallow-backup. 94 | 95 | Written by Aaron Lichtman (@alichtman). 96 | 97 | Options: 98 | 99 | --add-dot TEXT Add a dotfile or dotfolder to config by path. 100 | --backup-all Full back up. 101 | --backup-configs Back up app config files. 102 | --backup-dots Back up dotfiles. 103 | --backup-fonts Back up installed fonts. 104 | --backup-packages Back up package libraries. 105 | --delete-config Delete config file. 106 | --destroy-backup Delete backup directory. 107 | --dry-run Don't backup or reinstall any files, just give 108 | verbose output. 109 | 110 | --new-path TEXT Input a new back up directory path. 111 | --no-new-backup-path-prompt Skip setting new back up directory path prompt. 112 | --no-splash Don't display splash screen. 113 | --reinstall-all Full reinstallation. 114 | --reinstall-configs Reinstall configs. 115 | --reinstall-dots Reinstall dotfiles and dotfolders. 116 | --reinstall-fonts Reinstall fonts. 117 | --reinstall-packages Reinstall packages. 118 | --remote TEXT Set remote URL for the git repo. 119 | 120 | --edit Open config file in $EDITOR. 121 | -v, --version Display version and author info. 122 | -h, -help, --help Show this message and exit. 123 | ``` 124 | 125 | ### Recipes 126 | 127 | --- 128 | 129 | #### Maintain a separate repo for your dotfiles 130 | 131 | `shallow-backup` makes this easy! After making your first backup, `cd` into the `dotfiles/` directory and run `$ git init`. Create a `.gitignore`, and a create / set up (link the upstream remote, etc) a new repo on your favorite version control platform. With operations involving the parent `shallow-backup` repo, `shallow-backup` will prompt you interactively to update the nested submodule. After that is taken care of, `shallow-backup` will move on to updating the parent. The `dotfiles` repo will be tracked as a submodule. 132 | 133 | #### Synchronize dotfiles on multiple computers 134 | 135 | Run `shallow-backup --backup-dots` on the first computer. Make a commit and push to the remote. Then pull these changes down on the second computer. Run `shallow-backup --backup-dots` on the second computer, and resolve the merge conflicts. Once you have a final version you're happy with, make a commit, push it, and run `shallow-backup --reinstall-dots`. On the first computer, pull the changes and run `shallow-backup --reinstall-dots`. Your changes are now sync'd across both computers and the remote repository. 136 | 137 | #### Reinstall dotfiles from a backup 138 | 139 | To reinstall your dotfiles, clone your dotfiles repo and make sure your shallow-backup config path can be found at either `~/.config/shallow-backup.conf` or `$XDG_CONFIG_HOME/.shallow_backup.conf`. Set the `backup-path` key in the config to the path of your cloned dotfiles. Then run `$ shallow-backup --reinstall-dots`. 140 | 141 | When reinstalling your dotfiles, the top level `.git/`, `.gitignore`, `img/` and `README.md` files and directories are ignored. 142 | 143 | ### `zsh` Completions 144 | 145 | Available in [`zsh-users/zsh-completions`](https://github.com/zsh-users/zsh-completions/blob/master/src/_shallow-backup). Follow the [installation instructions here](https://github.com/zsh-users/zsh-completions/tree/master#using-zsh-frameworks). 146 | 147 | ### What can I back up? 148 | 149 | --- 150 | 151 | By default, `shallow-backup` backs these up. 152 | 153 | 1. Dotfiles and dotfolders 154 | 155 | - `.bashrc` 156 | - `.bash_profile` 157 | - `.gitconfig` 158 | - `.pypirc` 159 | - `.config/shallow-backup.json` 160 | - `.ssh/` 161 | - `.vim/` 162 | - `.zshrc` 163 | 164 | 2. App Config Files 165 | 166 | - VSCode 167 | - Sublime Text 2/3 168 | - Terminal.app 169 | 170 | 3. Installed Packages 171 | 172 | - `brew` and `cask` 173 | - `cargo` 174 | - `gem` 175 | - `pip` 176 | - `pip3` 177 | - `npm` 178 | - `macports` 179 | - `VSCode` Extensions 180 | - `Sublime Text 2/3` Packages 181 | - System Applications 182 | 183 | 4. User installed `fonts`. 184 | 185 | ### Configuration 186 | 187 | If you'd like to modify which files are backed up, you can edit the `JSON` config file. This file is looked for in the following locations, in this order: 188 | 189 | 1. `$SHALLOW_BACKUP_CONFIG_DIR/shallow-backup.json` 190 | 2. `$XDG_CONFIG_HOME/shallow-backup.json` 191 | 3. `~/.config/shallow-backup.json` 192 | 193 | #### Conditional Backup and Reinstallation 194 | 195 | > **Warning** 196 | > This feature allows code execution (by design). If untrusted users can write to your config, they can achieve code execution next time you invoke `shallow-backup` _backup_ or _reinstall_ functions. Starting in `v5.2`, the config file will have default permissions of `644`, and a warning will be printed if others can write to the config. 197 | 198 | Every key under dotfiles has two optional subkeys: `backup_condition` and `reinstall_condition`. Both of these accept expressions that will be evaluated with `bash`. An empty string (`""`) is the default value, and is considered to be `True`. If the return value of the expression is `0`, this is considered `True`. Otherwise, it is `False`. This lets you do simple things like preventing backup with: 199 | 200 | ```javascript 201 | // Because `$ false` returns 1 202 | "backup_condition": "false" 203 | ``` 204 | 205 | And also more complicated things like only backing up certain files if an environment variable is set: 206 | 207 | ```javascript 208 | "backup_condition": "[[ -n \"$ENV_VAR\" ]]" 209 | ``` 210 | 211 | Here's an example config based on my [dotfiles](https://www.github.com/alichtman/dotfiles): 212 | 213 | ```json 214 | { 215 | "backup_path": "~/shallow-backup", 216 | "lowest_supported_version": "5.0.0a", 217 | "dotfiles": { 218 | ".config/agignore": { 219 | "backup_condition": "uname -a | grep Darwin", 220 | "reinstall_condition": "uname -a | grep Darwin" 221 | }, 222 | ".config/git/gitignore_global": { }, 223 | ".config/jrnl/jrnl.yaml": { }, 224 | ".config/kitty": { }, 225 | ".config/nvim": { }, 226 | ".config/pycodestyle": { }, 227 | ... 228 | ".zshenv": { } 229 | }, 230 | "root-gitignore": [ 231 | ".DS_Store", 232 | "dotfiles/.config/nvim/.netrwhist", 233 | "dotfiles/.config/nvim/spell/en.utf-8.add", 234 | "dotfiles/.config/ranger/plugins/ranger_devicons", 235 | "dotfiles/.config/zsh/.zcompdump*", 236 | "dotfiles/.pypirc", 237 | "dotfiles/.ssh" 238 | ], 239 | "dotfiles-gitignore": [ 240 | ".DS_Store", 241 | ".config/nvim/.netrwhist", 242 | ".config/nvim/spell/en.utf-8.add*", 243 | ".config/ranger/plugins/*", 244 | ".config/zsh/.zcompdump*", 245 | ".config/zsh/.zinit", 246 | ".config/tmux/plugins", 247 | ".config/tmux/resurrect", 248 | ".pypirc", 249 | ".ssh/*" 250 | ], 251 | "config_mapping": { 252 | "/Users/alichtman/Library/Application Support/Sublime Text 2": "sublime2", 253 | "/Users/alichtman/Library/Application Support/Sublime Text 3": "sublime3", 254 | "/Users/alichtman/Library/Application Support/Code/User/settings.json": "vscode/settings", 255 | "/Users/alichtman/Library/Application Support/Code/User/Snippets": "vscode/Snippets", 256 | "/Users/alichtman/Library/Application Support/Code/User/keybindings.json": "vscode/keybindings", 257 | "/Users/alichtman/Library/Preferences/com.apple.Terminal.plist": "terminal_plist" 258 | } 259 | } 260 | ``` 261 | 262 | ### Git Integration 263 | 264 | --- 265 | 266 | #### A Word of Caution 267 | 268 | This backup tool is git-integrated, meaning that you can easily store your backups remotely (on GitHub, for example.) Dotfiles and configuration files may contain sensitive information like API keys and ssh keys, and you don't want to make those public. To make sure no sensitive files are uploaded accidentally, `shallow-backup` creates a `.gitignore` file if it can't find one in the directory. It excludes `.ssh/` and `.pypirc` by default. It's safe to remove these restrictions if you're pushing to a remote private repository, or you're only backing up locally. To do this, you should clear the `.gitignore` file without deleting it. 269 | 270 | _If you choose to back up to a public repository, look at every file you're backing up to make sure you want it to be public._ 271 | 272 | > [!NOTE] 273 | > As of `v6.2`, `trufflehog` is run as a required precommit hook and will detect secrets. 274 | 275 | #### .gitignore 276 | 277 | As of `v4.0`, any `.gitignore` changes should be made in the `shallow-backup` config file. `.gitignore` changes that are meant to apply to all directories should be under the `root-gitignore` key. Dotfile specific gitignores should be placed under the `dotfiles-gitignore` key. The original `default-gitignore` key in the config is still supported for backwards compatibility, however, converting to the new config format is strongly encouraged. 278 | 279 | ### Output Structure 280 | 281 | --- 282 | 283 | ```shell 284 | shallow_backup/ 285 | ├── configs 286 | │   ├── plist 287 | │   │   └── com.apple.Terminal.plist 288 | │   ├── sublime_2 289 | │   │   └── ... 290 | │   └── sublime_3 291 | │   └── ... 292 | ├── dotfiles 293 | │ ├── .bash_profile 294 | │ ├── .bashrc 295 | │ ├── .gitconfig 296 | │ ├── .pypirc 297 | │ ├── ... 298 | │ ├── shallow-backup.json 299 | │   ├── .ssh/ 300 | │   │   └── known_hosts 301 | │   ├── .vim/ 302 | │ └── .zshrc 303 | ├── fonts 304 | │   ├── AllerDisplay.ttf 305 | │   ├── Aller_Bd.ttf 306 | │   ├── ... 307 | │   ├── Ubuntu Mono derivative Powerline Italic.ttf 308 | │   └── Ubuntu Mono derivative Powerline.ttf 309 | └── packages 310 | ├── brew-cask_list.txt 311 | ├── brew_list.txt 312 | ├── cargo_list.txt 313 | ├── gem_list.txt 314 | ├── installed_apps_list.txt 315 | ├── npm_list.txt 316 | ├── macports_list.txt 317 | ├── pip_list.txt 318 | └── sublime3_list.txt 319 | ``` 320 | 321 | ### Reinstalling Dotfiles 322 | 323 | --- 324 | 325 | To reinstall your dotfiles, clone your dotfiles repo and make sure your shallow-backup config path can be found at either `~/.config/shallow-backup.json` or `$XDG_CONFIG_HOME/.shallow_backup.json`. Set the `backup-path` key in the config to the path of your cloned dotfiles. Then run `$ shallow-backup -reinstall-dots`. 326 | 327 | When reinstalling your dotfiles, the top level `.git/`, `.gitignore`, `img/` and `README.md` files and directories are ignored. 328 | 329 | ### Want to Contribute? 330 | 331 | --- 332 | 333 | Check out `CONTRIBUTING.md` and the `docs` directory. 334 | -------------------------------------------------------------------------------- /docs/Troubleshooting.md: -------------------------------------------------------------------------------- 1 | ## Troubleshooting 2 | 3 | **Error Reading Config** 4 | 5 | Try removing the config file `$ rm ~/.shallow-backup` and running the program again. Config files from below version 2.0 are incompatible with the config files used after. 6 | 7 | **Missing Files After Changing Backup Path** 8 | 9 | Don't worry! They'll all be in the git repo, which was moved to the new path. The only exception will be any files that were not under git version control. 10 | 11 | Try to `cd` into the backup directory and run `$ git log`. Then you can grab the last commit hash and checkout that revision. Your files will be there. 12 | -------------------------------------------------------------------------------- /docs/design-guide.md: -------------------------------------------------------------------------------- 1 | ## Design Guide 2 | 3 | **Philosophy** 4 | 5 | + Design to protect the user. It should be hard (or impossible) to misuse this software. 6 | + Make the experience as fluid and seamless for the user as possible, at the expense of more complicated code. 7 | + Support for the greatest amount of scriptability. 8 | 9 | **Printing** 10 | 11 | Each notable action that is taken should be printed in a stylized manner, before it occurs. This serves both to update the user on progress and also to make debug messages readily accessible when things go wrong. 12 | 13 | Status messages should be colored and bolded. 14 | Paths should be colored and not be bolded. 15 | 16 | **Colors** 17 | 18 | + RED: Errors and "dangerous" actions, like removal. 19 | + GREEN: Prompt questions. 20 | + YELLOW: Git-related. 21 | + BLUE: Not sure tbh. Log messages maybe? 22 | -------------------------------------------------------------------------------- /docs/development-guide.md: -------------------------------------------------------------------------------- 1 | ## Development Guide 2 | 3 | ### Running the code 4 | 5 | ```bash 6 | $ pipenv shell 7 | $ pipenv install --dev 8 | $ python3 -m shallow_backup 9 | ``` 10 | 11 | ### Testing 12 | 13 | ```bash 14 | $ pytest 15 | 16 | #### 17 | # Code Coverage 18 | # NOTE: This makes some tests fail -- not sure why. Just ignore those for now. 19 | #### 20 | 21 | $ py.test --cov --cov-report html:code_coverage 22 | $ open code_coverage/index.html 23 | ``` 24 | 25 | Make sure all existing tests pass before opening a PR! 26 | Also, add any necessary tests for new code. 27 | 28 | 29 | ### Deployment 30 | 31 | Make a version bump commit, like: 32 | 33 | ```diff 34 | From d71b903dacd5eeea9d0be68ef3022817f9bac601 Mon Sep 17 00:00:00 2001 35 | From: Aaron Lichtman 36 | Date: Sun, 19 Jun 2022 05:46:06 -0500 37 | Subject: [PATCH] Version bump to v5.2 38 | 39 | --- 40 | shallow_backup/constants.py | 2 +- 41 | 1 file changed, 1 insertion(+), 1 deletion(-) 42 | 43 | diff --git a/shallow_backup/constants.py b/shallow_backup/constants.py 44 | index 7edcb5c3..13b949c4 100644 45 | --- a/shallow_backup/constants.py 46 | +++ b/shallow_backup/constants.py 47 | @@ -1,6 +1,6 @@ 48 | class ProjInfo: 49 | PROJECT_NAME = 'shallow-backup' 50 | - VERSION = '5.1' 51 | + VERSION = '5.2' 52 | AUTHOR_GITHUB = 'alichtman' 53 | AUTHOR_FULL_NAME = 'Aaron Lichtman' 54 | DESCRIPTION = "Easily create lightweight backups of installed packages, dotfiles, and more." 55 | ``` 56 | 57 | And then run `scripts/release.sh` from the project root. 58 | 59 | ### Code Style 60 | 61 | You should follow the code style already established in the code base. 62 | 63 | PEP8 is generally to be followed, but I think that prettier code to look at in an editor is more important that strictly following PEP8. 64 | 65 | All files should end with a new line. 66 | 67 | PRs with changes in indentation style _will not be merged._ Tabs (width of 4 spaces) should be used. 68 | 69 | ### Continuous Integration Testing and Static Analysis 70 | 71 | + [Travis CI](https://travis-ci.com/alichtman/shallow-backup) 72 | + [Codacy](https://app.codacy.com/project/alichtman/shallow-backup/dashboard) 73 | + [CodeClimate](https://codeclimate.com/github/alichtman/shallow-backup) 74 | + [Coveralls](https://coveralls.io/github/alichtman/shallow-backup) 75 | -------------------------------------------------------------------------------- /img/shallow-backup-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alichtman/shallow-backup/e7d3bf93b4f35069f00f6e89da05959499d004b9/img/shallow-backup-demo.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | colorama==0.4.3 3 | GitPython>=3.1.32 4 | inquirer==2.6.3 5 | pytest==7.4.2 6 | pytest-cov==2.8.1 7 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Release script for shallow-backup 3 | 4 | set -e 5 | 6 | # Check if .git directory exists 7 | if [[ ! -d ".git" ]]; then 8 | echo 'Must be run from project root directory!'; 9 | exit 1; 10 | fi 11 | 12 | # Check if inside the pipenv virtual environment 13 | if [[ "$PIPENV_ACTIVE" != "1" ]]; then 14 | echo 'Must be inside the pipenv virtual environment!'; 15 | exit 1; 16 | fi 17 | 18 | # Check if on main 19 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 20 | if [[ "$BRANCH" != "main" ]]; then 21 | echo 'Must be on main branch to cut a release!'; 22 | exit 1; 23 | fi 24 | 25 | # Check if main is dirty 26 | if [[ -n $(git status -s) ]]; then 27 | echo 'main branch dirty! Aborting.'; 28 | exit 1; 29 | fi 30 | 31 | SB_VERSION_NO_V="$(python3 -c "from shallow_backup.constants import ProjInfo; print(ProjInfo.VERSION)")" 32 | SB_VERSION="v$SB_VERSION_NO_V" 33 | 34 | read -r -p "Release shallow-backup $SB_VERSION? Version bump should already be committed and pushed. [y/N] " response 35 | case "$response" in 36 | [yY][eE][sS]|[yY]) 37 | echo "Releasing." 38 | ;; 39 | *) 40 | echo "Aborting." 41 | exit; 42 | ;; 43 | esac 44 | 45 | git checkout main && git pull 46 | git tag -a "$SB_VERSION" -m "shallow-backup $SB_VERSION" && git push 47 | github_changelog_generator --user alichtman --project shallow-backup 48 | git add CHANGELOG.md && git commit -m "Add CHANGELOG for $SB_VERSION" && git push 49 | echo "Generating distribution files..." 50 | rm -rf dist/* && python3 setup.py sdist 51 | echo "Creating GH release for $SB_VERSION..." 52 | gh release create "$SB_VERSION" dist/shallow_backup-$SB_VERSION_NO_V.tar.gz -F CHANGELOG.md 53 | echo "Uploading to pypi..." 54 | twine upload --repository pypi dist/* 55 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | 4 | [bdist_wheel] 5 | # This flag says to generate wheels that support both Python 2 and Python 6 | # 3. If your code will not run unchanged on both Python 2 and 3, you will 7 | # need to generate separate wheels for each Python version that you 8 | # support. 9 | universal=1 10 | 11 | [tool:pytest] 12 | # When https://github.com/pytest-dev/pytest-cov/issues/242 is fixed, uncomment this. 13 | #addopts = -v --doctest-modules --ignore=setup.py --cov=shallow_backup 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from codecs import open 3 | from setuptools import setup 4 | from shallow_backup.constants import ProjInfo 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | # Get the long description from the README file 9 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name=ProjInfo.PROJECT_NAME, 14 | version=ProjInfo.VERSION, 15 | description=ProjInfo.DESCRIPTION, 16 | long_description_content_type="text/markdown", 17 | long_description=long_description, 18 | url=ProjInfo.URL, 19 | author=ProjInfo.AUTHOR_GITHUB, 20 | author_email="aaronlichtman@gmail.com", 21 | # Classifiers help users find your project by categorizing it. 22 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 23 | classifiers=[ # Optional 24 | "Development Status :: 4 - Beta", 25 | "Environment :: MacOS X", 26 | "Intended Audience :: Developers", 27 | "Intended Audience :: End Users/Desktop", 28 | "Intended Audience :: System Administrators", 29 | "Topic :: System :: Installation/Setup", 30 | "Topic :: System :: Archiving :: Backup", 31 | "Topic :: System :: Operating System", 32 | "Topic :: Documentation", 33 | "Topic :: Utilities", 34 | "Operating System :: MacOS", 35 | "Operating System :: POSIX :: Linux", 36 | "Operating System :: Unix", 37 | "Natural Language :: English", 38 | "License :: OSI Approved :: MIT License", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3.4", 41 | "Programming Language :: Python :: 3.5", 42 | "Programming Language :: Python :: 3.6", 43 | "Programming Language :: Python :: 3.7", 44 | ], 45 | # This field adds keywords for your project which will appear on the 46 | # project page. What does your project relate to? 47 | # 48 | # Note that this is a string of words separated by whitespace, not a list. 49 | keywords="backup documentation system dotfiles install list configuration", # Optional 50 | packages=["shallow_backup"], 51 | install_requires=[ 52 | "inquirer>=2.2.0", 53 | "colorama>=0.3.9", 54 | "gitpython>=3.1.20", 55 | "Click", 56 | ], 57 | # To provide executable scripts, use entry points in preference to the 58 | # "scripts" keyword. Entry points provide cross-platform support and allow 59 | # `pip` to create the appropriate form of executable for the target 60 | # platform. 61 | # 62 | # For example, the following would provide a command called `sample` which 63 | # executes the function `main` from this package when invoked: 64 | entry_points={"console_scripts": "shallow-backup=shallow_backup.__main__:cli"}, 65 | # List additional URLs that are relevant to your project as a dict. 66 | # 67 | # This field corresponds to the "Project-URL" metadata fields: 68 | # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use 69 | # 70 | # Examples listed include a pattern for specifying where the package tracks 71 | # issues, where the source is hosted, where to say thanks to the package 72 | # maintainers, and where to support the project financially. The key is 73 | # what's used to render the link text on PyPI. 74 | project_urls={ 75 | "Bug Reports": ProjInfo.BUG_REPORT_URL, 76 | "Donations": "https://www.patreon.com/alichtman", 77 | }, 78 | ) 79 | -------------------------------------------------------------------------------- /shallow_backup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alichtman/shallow-backup/e7d3bf93b4f35069f00f6e89da05959499d004b9/shallow_backup/__init__.py -------------------------------------------------------------------------------- /shallow_backup/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import click 5 | from colorama import Fore 6 | 7 | from .backup import ( 8 | backup_all, 9 | backup_configs, 10 | backup_dotfiles, 11 | backup_fonts, 12 | backup_packages, 13 | ) 14 | from .config import ( 15 | add_dot_path_to_config, 16 | check_insecure_config_permissions, 17 | delete_config_file, 18 | get_config, 19 | safe_create_config, 20 | write_config, 21 | ) 22 | from .git_wrapper import ( 23 | create_gitignore, 24 | git_add_all_commit_push, 25 | git_set_remote, 26 | handle_separate_git_dir_in_dotfiles, 27 | safe_git_init, 28 | ) 29 | from .printing import print_path_blue, print_red_bold, print_version_info 30 | from .prompts import ( 31 | add_to_config_prompt, 32 | edit_config, 33 | git_url_prompt, 34 | main_menu_prompt, 35 | path_update_prompt, 36 | prompt_yes_no, 37 | remove_from_config_prompt, 38 | splash_screen, 39 | ) 40 | from .reinstall import ( 41 | reinstall_all_sb, 42 | reinstall_configs_sb, 43 | reinstall_dots_sb, 44 | reinstall_fonts_sb, 45 | reinstall_packages_sb, 46 | ) 47 | from .upgrade import check_if_config_upgrade_needed 48 | from .utils import ( 49 | check_if_path_is_valid_dir, 50 | destroy_backup_dir, 51 | expand_to_abs_path, 52 | mkdir_warn_overwrite, 53 | ) 54 | 55 | 56 | # custom help options 57 | @click.command(context_settings=dict(help_option_names=["-h", "-help", "--help"])) 58 | @click.option( 59 | "--add-dot", default=None, help="Add a dotfile or dotfolder to config by path." 60 | ) 61 | @click.option( 62 | "--backup-all", "backup_all_flag", is_flag=True, default=False, help="Full back up." 63 | ) 64 | @click.option( 65 | "--backup-configs", 66 | "backup_configs_flag", 67 | is_flag=True, 68 | default=False, 69 | help="Back up app config files.", 70 | ) 71 | @click.option( 72 | "--backup-dots", 73 | "backup_dots_flag", 74 | is_flag=True, 75 | default=False, 76 | help="Back up dotfiles.", 77 | ) 78 | @click.option( 79 | "--backup-fonts", 80 | "backup_fonts_flag", 81 | is_flag=True, 82 | default=False, 83 | help="Back up installed fonts.", 84 | ) 85 | @click.option( 86 | "--backup-packages", 87 | "backup_packages_flag", 88 | is_flag=True, 89 | default=False, 90 | help="Back up package libraries.", 91 | ) 92 | @click.option( 93 | "--delete-config", is_flag=True, default=False, help="Delete config file." 94 | ) 95 | @click.option( 96 | "--destroy-backup", is_flag=True, default=False, help="Delete backup directory." 97 | ) 98 | @click.option( 99 | "--dry-run", 100 | is_flag=True, 101 | default=False, 102 | help="Don't backup or reinstall any files, just give verbose output.", 103 | ) 104 | @click.option("--new-path", default=None, help="Input a new back up directory path.") 105 | @click.option( 106 | "--no-new-backup-path-prompt", 107 | is_flag=True, 108 | default=False, 109 | help="Skip setting new back up directory path prompt.", 110 | ) 111 | @click.option( 112 | "--no-splash", is_flag=True, default=False, help="Don't display splash screen." 113 | ) 114 | @click.option( 115 | "--reinstall-all", is_flag=True, default=False, help="Full reinstallation." 116 | ) 117 | @click.option( 118 | "--reinstall-configs", is_flag=True, default=False, help="Reinstall configs." 119 | ) 120 | @click.option( 121 | "--reinstall-dots", 122 | is_flag=True, 123 | default=False, 124 | help="Reinstall dotfiles and dotfolders.", 125 | ) 126 | @click.option("--reinstall-fonts", is_flag=True, default=False, help="Reinstall fonts.") 127 | @click.option( 128 | "--reinstall-packages", is_flag=True, default=False, help="Reinstall packages." 129 | ) 130 | @click.option("--remote", default=None, help="Set remote URL for the git repo.") 131 | @click.option( 132 | "--edit", is_flag=True, default=False, help="Open config file in $EDITOR." 133 | ) 134 | @click.option( 135 | "--version", 136 | "-v", 137 | is_flag=True, 138 | default=False, 139 | help="Display version and author info.", 140 | ) 141 | def cli( 142 | add_dot, 143 | backup_configs_flag, 144 | delete_config, 145 | destroy_backup, 146 | backup_dots_flag, 147 | dry_run, 148 | backup_fonts_flag, 149 | backup_all_flag, 150 | new_path, 151 | no_splash, 152 | no_new_backup_path_prompt, 153 | backup_packages_flag, 154 | reinstall_all, 155 | reinstall_configs, 156 | reinstall_dots, 157 | reinstall_fonts, 158 | reinstall_packages, 159 | remote, 160 | edit, 161 | version, 162 | ): 163 | """ 164 | \b 165 | Easily back up installed packages, dotfiles, and more. 166 | You can edit which files are backed up in ~/.shallow-backup. 167 | 168 | Written by Aaron Lichtman (@alichtman). 169 | """ 170 | safe_create_config() 171 | check_if_config_upgrade_needed() 172 | check_insecure_config_permissions() 173 | 174 | # Process CLI args 175 | admin_action = any([add_dot, delete_config, destroy_backup, edit, version]) 176 | has_cli_arg = any( 177 | [ 178 | no_new_backup_path_prompt, 179 | backup_all_flag, 180 | backup_dots_flag, 181 | backup_packages_flag, 182 | backup_fonts_flag, 183 | backup_configs_flag, 184 | reinstall_dots, 185 | reinstall_fonts, 186 | reinstall_all, 187 | reinstall_configs, 188 | reinstall_packages, 189 | ] 190 | ) 191 | skip_prompt = any( 192 | [ 193 | backup_all_flag, 194 | backup_dots_flag, 195 | backup_configs_flag, 196 | backup_packages_flag, 197 | backup_fonts_flag, 198 | reinstall_packages, 199 | reinstall_configs, 200 | reinstall_dots, 201 | reinstall_fonts, 202 | ] 203 | ) 204 | 205 | backup_config = get_config() 206 | 207 | # Perform administrative action and exit. 208 | if admin_action: 209 | if version: 210 | print_version_info() 211 | elif delete_config: 212 | delete_config_file() 213 | elif destroy_backup: 214 | backup_home_path = expand_to_abs_path(get_config()["backup_path"]) 215 | destroy_backup_dir(backup_home_path) 216 | elif edit: 217 | edit_config() 218 | elif add_dot: 219 | new_config = add_dot_path_to_config(backup_config, add_dot) 220 | write_config(new_config) 221 | sys.exit() 222 | 223 | # Start CLI 224 | if not no_splash: 225 | splash_screen() 226 | 227 | # User entered a new path, so update the config 228 | if new_path: 229 | abs_path = os.path.abspath(new_path) 230 | 231 | if not check_if_path_is_valid_dir(abs_path): 232 | sys.exit(1) 233 | 234 | print_path_blue("\nUpdating shallow-backup path to:", abs_path) 235 | backup_config["backup_path"] = abs_path 236 | write_config(backup_config) 237 | 238 | # User didn't enter any CLI args so prompt for path update before showing menu 239 | elif not has_cli_arg: 240 | path_update_prompt(backup_config) 241 | 242 | # Create backup directory and do git setup 243 | backup_home_path = expand_to_abs_path(get_config()["backup_path"]) 244 | mkdir_warn_overwrite(backup_home_path) 245 | repo, new_git_repo_created = safe_git_init(backup_home_path) 246 | create_gitignore(backup_home_path, "root-gitignore") 247 | 248 | # Prompt user for remote URL if needed 249 | if new_git_repo_created and not remote: 250 | git_url_prompt(repo) 251 | 252 | # Set remote URL from CLI arg 253 | if remote: 254 | git_set_remote(repo, remote) 255 | 256 | dotfiles_path = os.path.join(backup_home_path, "dotfiles") 257 | create_gitignore(dotfiles_path, "dotfiles-gitignore") 258 | 259 | configs_path = os.path.join(backup_home_path, "configs") 260 | packages_path = os.path.join(backup_home_path, "packages") 261 | fonts_path = os.path.join(backup_home_path, "fonts") 262 | 263 | # Command line options 264 | if skip_prompt: 265 | if reinstall_packages: 266 | reinstall_packages_sb(packages_path, dry_run=dry_run) 267 | elif reinstall_configs: 268 | reinstall_configs_sb(configs_path, dry_run=dry_run) 269 | elif reinstall_fonts: 270 | reinstall_fonts_sb(fonts_path, dry_run=dry_run) 271 | elif reinstall_dots: 272 | reinstall_dots_sb(dotfiles_path, dry_run=dry_run) 273 | elif reinstall_all: 274 | reinstall_all_sb( 275 | dotfiles_path, packages_path, fonts_path, configs_path, dry_run=dry_run 276 | ) 277 | elif backup_all_flag: 278 | backup_all( 279 | dotfiles_path, 280 | packages_path, 281 | fonts_path, 282 | configs_path, 283 | dry_run=dry_run, 284 | skip=True, 285 | ) 286 | git_add_all_commit_push(repo, dry_run=dry_run) 287 | elif backup_dots_flag: 288 | backup_dotfiles(dotfiles_path, dry_run=dry_run, skip=True) 289 | # The reason that dotfiles/.git is special cased, and none of the others are is because maintaining a separate git repo for dotfiles is a common use case. 290 | handle_separate_git_dir_in_dotfiles(dotfiles_path, dry_run) 291 | git_add_all_commit_push(repo, dry_run=dry_run) 292 | elif backup_configs_flag: 293 | backup_configs(configs_path, dry_run=dry_run, skip=True) 294 | git_add_all_commit_push(repo, dry_run=dry_run) 295 | elif backup_packages_flag: 296 | backup_packages(packages_path, dry_run=dry_run, skip=True) 297 | git_add_all_commit_push(repo, dry_run=dry_run) 298 | elif backup_fonts_flag: 299 | backup_fonts(fonts_path, dry_run=dry_run, skip=True) 300 | git_add_all_commit_push(repo, dry_run=dry_run) 301 | # No command line options, show action menu and process selected option. 302 | else: 303 | selection = main_menu_prompt() 304 | action, _, target = selection.rpartition(" ") 305 | if action == "back up": 306 | if target == "all": 307 | backup_all(dotfiles_path, packages_path, fonts_path, configs_path) 308 | handle_separate_git_dir_in_dotfiles(dotfiles_path, dry_run=dry_run) 309 | git_add_all_commit_push(repo) 310 | elif target == "dotfiles": 311 | backup_dotfiles(dotfiles_path) 312 | handle_separate_git_dir_in_dotfiles(dotfiles_path, dry_run) 313 | git_add_all_commit_push(repo) 314 | elif target == "configs": 315 | backup_configs(configs_path) 316 | git_add_all_commit_push(repo) 317 | elif target == "packages": 318 | backup_packages(packages_path) 319 | git_add_all_commit_push(repo) 320 | elif target == "fonts": 321 | backup_fonts(fonts_path) 322 | git_add_all_commit_push(repo) 323 | elif action == "reinstall": 324 | if target == "packages": 325 | reinstall_packages_sb(packages_path) 326 | elif target == "configs": 327 | reinstall_configs_sb(configs_path) 328 | elif target == "fonts": 329 | reinstall_fonts_sb(fonts_path) 330 | elif target == "dotfiles": 331 | reinstall_dots_sb(dotfiles_path) 332 | elif target == "all": 333 | reinstall_all_sb(dotfiles_path, packages_path, fonts_path, configs_path) 334 | elif target == "config": 335 | if action.startswith("edit"): 336 | edit_config() 337 | elif action.startswith("add"): 338 | add_to_config_prompt() 339 | elif action.startswith("remove"): 340 | remove_from_config_prompt() 341 | elif action == "destroy": 342 | if prompt_yes_no( 343 | "Erase backup directory: {}?".format(backup_home_path), Fore.RED 344 | ): 345 | destroy_backup_dir(backup_home_path) 346 | else: 347 | print_red_bold( 348 | "Exiting to prevent accidental deletion of backup directory." 349 | ) 350 | 351 | sys.exit() 352 | 353 | 354 | if __name__ == "__main__": 355 | cli() 356 | -------------------------------------------------------------------------------- /shallow_backup/backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from shlex import quote 4 | from colorama import Fore 5 | import multiprocessing as mp 6 | from pathlib import Path 7 | from shutil import copyfile 8 | from .utils import * 9 | from .printing import * 10 | from .compatibility import * 11 | from .config import get_config 12 | 13 | 14 | def backup_dotfiles( 15 | backup_dest_path, dry_run=False, home_path=os.path.expanduser("~"), skip=False 16 | ): 17 | """ 18 | Create `dotfiles` dir and makes copies of dotfiles and dotfolders. 19 | Assumes that dotfiles are stored in the home directory. 20 | :param skip: Boolean flag to skip prompting for overwrite. Used for scripting. 21 | :param backup_dest_path: Destination path for dotfiles. Like, ~/shallow-backup/dotfiles. Used in tests. 22 | :param home_path: Path where dotfiles will be found. $HOME by default. 23 | :param dry_run: Flag for determining if debug info should be shown or copying should occur. 24 | """ 25 | print_section_header("DOTFILES", Fore.BLUE) 26 | if not dry_run: 27 | overwrite_dir_prompt_if_needed(backup_dest_path, skip) 28 | 29 | # get dotfolders and dotfiles 30 | config = get_config()["dotfiles"] 31 | 32 | # Aggregate pairs of [(Installed dotfile path, backup dest path)] in a list to be sorted into 33 | # dotfiles and dotfolders later 34 | dot_path_pairs = [] 35 | for dotfile_path_from_config, options in config.items(): 36 | # Evaluate condition, if specified. Skip if the command doesn't return true. 37 | condition_success = evaluate_condition( 38 | condition=options.get("backup_condition", ""), 39 | backup_or_reinstall="backup", 40 | dotfile_path=dotfile_path_from_config, 41 | ) 42 | if not condition_success: 43 | continue 44 | 45 | # If a file path in the config starts with /, it's a full path like /etc/ssh/ 46 | if dotfile_path_from_config.startswith("/"): 47 | installed_dotfile_path = dotfile_path_from_config 48 | installed_dotfile_path = quote(":" + installed_dotfile_path[1:]) 49 | backup_dotfile_path = quote( 50 | os.path.join(backup_dest_path, installed_dotfile_path) 51 | ) 52 | dot_path_pairs.append((dotfile_path_from_config, backup_dotfile_path)) 53 | 54 | else: # Dotfile living in $HOME 55 | installed_dotfile_path = quote( 56 | os.path.join(home_path, dotfile_path_from_config) 57 | ) 58 | backup_dotfile_path = quote( 59 | os.path.join(backup_dest_path, dotfile_path_from_config) 60 | ) 61 | dot_path_pairs.append((installed_dotfile_path, backup_dotfile_path)) 62 | 63 | # Separate dotfiles and dotfolders 64 | dotfolders_mp_in = [] 65 | dotfiles_mp_in = [] 66 | for path_pair in dot_path_pairs: 67 | installed_path = path_pair[0] 68 | if os.path.isdir(installed_path): 69 | dotfolders_mp_in.append(path_pair) 70 | else: 71 | dotfiles_mp_in.append(path_pair) 72 | 73 | # Print source -> dest and skip the copying step 74 | if dry_run: 75 | print_yellow_bold("Dotfiles:") 76 | for source, dest in dotfiles_mp_in: 77 | print_dry_run_copy_info(source, dest) 78 | 79 | print_yellow_bold("\nDotfolders:") 80 | for source, dest in dotfolders_mp_in: 81 | print_dry_run_copy_info(source, dest) 82 | 83 | return 84 | 85 | # Fix https://github.com/alichtman/shallow-backup/issues/230 86 | for dest_path in [path_pair[1] for path_pair in dotfiles_mp_in + dotfolders_mp_in]: 87 | # print(f"Creating: {os.path.split(dest_path)[0]}") 88 | safe_mkdir(os.path.split(dest_path)[0]) 89 | 90 | with mp.Pool(mp.cpu_count()): 91 | print_blue_bold("Backing up dotfolders...") 92 | for x in dotfolders_mp_in: 93 | p = mp.Process( 94 | target=copy_dir_if_valid, 95 | args=( 96 | x[0], 97 | x[1], 98 | ), 99 | ) 100 | p.start() 101 | p.join() 102 | 103 | print_blue_bold("Backing up dotfiles...") 104 | for x in dotfiles_mp_in: 105 | p = mp.Process( 106 | target=copyfile_with_exception_handler, 107 | args=( 108 | x[0], 109 | x[1], 110 | ), 111 | ) 112 | p.start() 113 | p.join() 114 | 115 | 116 | def backup_configs(backup_path, dry_run: bool = False, skip=False): 117 | """ 118 | Creates `configs` directory and places config backups there. 119 | Configs are application settings, generally. .plist files count. 120 | In the config file, the value of the configs dictionary is the dest 121 | path relative to the configs/ directory. 122 | """ 123 | print_section_header("CONFIGS", Fore.BLUE) 124 | # Don't clear any directories if this is a dry run 125 | if not dry_run: 126 | overwrite_dir_prompt_if_needed(backup_path, skip) 127 | config = get_config() 128 | 129 | print_blue_bold("Backing up configs...") 130 | 131 | # backup config files + dirs in backup_path// 132 | for config_path, target in config["config_mapping"].items(): 133 | dest = os.path.join(backup_path, target) 134 | 135 | if dry_run: 136 | print_dry_run_copy_info(config_path, dest) 137 | continue 138 | 139 | quoted_dest = quote(dest) 140 | if os.path.isdir(config_path): 141 | copytree(config_path, quoted_dest, symlinks=True) 142 | elif os.path.isfile(config_path): 143 | parent_dir = Path(dest).parent 144 | safe_mkdir(parent_dir) 145 | copyfile(config_path, quoted_dest) 146 | 147 | 148 | def backup_packages(backup_path, dry_run: bool = False, skip=False): 149 | """ 150 | Creates `packages` directory and places install list text files there. 151 | """ 152 | 153 | def run_cmd_if_no_dry_run(command, dest, dry_run) -> int: 154 | if dry_run: 155 | print_dry_run_copy_info(f"$ {command}", dest) 156 | # Return -1 for any processes depending on chained successful commands (npm) 157 | return -1 158 | else: 159 | return run_cmd_write_stdout(command, dest) 160 | 161 | print_section_header("PACKAGES", Fore.BLUE) 162 | if not dry_run: 163 | overwrite_dir_prompt_if_needed(backup_path, skip) 164 | 165 | # brew 166 | print_pkg_mgr_backup("brew") 167 | command = f"brew bundle dump --file {backup_path}/brew_list.txt" 168 | dest = f"{backup_path}/brew_list.txt" 169 | if not dry_run: 170 | if not run_cmd(command): 171 | print_yellow("brew package manager not found.") 172 | 173 | # ruby 174 | print_pkg_mgr_backup("gem") 175 | command = r"gem list | tail -n+1 | sed -E 's/\((default: )?(.*)\)/--version \2/'" 176 | dest = f"{backup_path}/gem_list.txt" 177 | run_cmd_if_no_dry_run(command, dest, dry_run) 178 | 179 | # cargo 180 | print_pkg_mgr_backup("cargo") 181 | command = ( 182 | r"cargo install --list | grep '^\w.*:$' | sed -E 's/ v(.*):$/ --version \1/'" 183 | ) 184 | dest = f"{backup_path}/cargo_list.txt" 185 | run_cmd_if_no_dry_run(command, dest, dry_run) 186 | 187 | # pip 188 | print_pkg_mgr_backup("pip") 189 | command = "pip list --format=freeze" 190 | dest = f"{backup_path}/pip_list.txt" 191 | run_cmd_if_no_dry_run(command, dest, dry_run) 192 | 193 | # pip3 194 | print_pkg_mgr_backup("pip3") 195 | command = "pip3 list --format=freeze" 196 | dest = f"{backup_path}/pip3_list.txt" 197 | run_cmd_if_no_dry_run(command, dest, dry_run) 198 | 199 | # npm 200 | print_pkg_mgr_backup("npm") 201 | command = "npm ls --global --json=true --depth=0" 202 | temp_file_path = f"{backup_path}/npm_temp_list.json" 203 | # If command is successful, go to the next parsing step. 204 | npm_backup_cmd_success = ( 205 | run_cmd_if_no_dry_run(command, temp_file_path, dry_run) == 0 206 | ) 207 | if npm_backup_cmd_success: 208 | npm_dest_file = f"{backup_path}/npm_list.txt" 209 | with open(temp_file_path, "r") as temp_file: 210 | npm_packages = json.load(temp_file).get("dependencies").keys() 211 | if len(npm_packages) >= 1: 212 | with open(npm_dest_file, "w") as dest: 213 | [dest.write(f"{package}\n") for package in npm_packages] 214 | os.remove(temp_file_path) 215 | 216 | # vscode extensions 217 | print_pkg_mgr_backup("VSCode") 218 | command = "code --list-extensions --show-versions" 219 | dest = f"{backup_path}/vscode_list.txt" 220 | run_cmd_if_no_dry_run(command, dest, dry_run) 221 | 222 | # macports 223 | print_pkg_mgr_backup("macports") 224 | command = "port installed requested" 225 | dest = f"{backup_path}/macports_list.txt" 226 | run_cmd_if_no_dry_run(command, dest, dry_run) 227 | 228 | # system installs 229 | print_pkg_mgr_backup("System Applications") 230 | applications_path = get_applications_dir() 231 | command = "ls {}".format(applications_path) 232 | dest = f"{backup_path}/system_apps_list.txt" 233 | run_cmd_if_no_dry_run(command, dest, dry_run) 234 | 235 | 236 | def backup_fonts(backup_path: str, dry_run: bool = False, skip: bool = False): 237 | """Copies all .ttf and .otf files in the to backup/fonts/""" 238 | print_section_header("FONTS", Fore.BLUE) 239 | if not dry_run: 240 | overwrite_dir_prompt_if_needed(backup_path, skip) 241 | print_blue("Copying '.otf' and '.ttf' fonts...") 242 | fonts_path = get_fonts_dir() 243 | if os.path.isdir(fonts_path): 244 | fonts = [ 245 | quote(os.path.join(fonts_path, font)) 246 | for font in os.listdir(fonts_path) 247 | if font.endswith(".otf") or font.endswith(".ttf") 248 | ] 249 | 250 | for font in fonts: 251 | dest = os.path.join(backup_path, font.split("/")[-1]) 252 | if os.path.exists(font): 253 | if dry_run: 254 | print_dry_run_copy_info(font, dest) 255 | else: 256 | copyfile(font, dest) 257 | else: 258 | print_red("Skipping fonts backup. No fonts directory found.") 259 | 260 | 261 | def backup_all( 262 | dotfiles_path, packages_path, fonts_path, configs_path, dry_run=False, skip=False 263 | ): 264 | """Complete backup procedure.""" 265 | backup_dotfiles(dotfiles_path, dry_run=dry_run, skip=skip) 266 | backup_packages(packages_path, dry_run=dry_run, skip=skip) 267 | backup_fonts(fonts_path, dry_run=dry_run, skip=skip) 268 | backup_configs(configs_path, dry_run=dry_run, skip=skip) 269 | 270 | 271 | # vim: autoindent noexpandtab tabstop=4 shiftwidth=4 272 | -------------------------------------------------------------------------------- /shallow_backup/compatibility.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | 4 | 5 | def get_os_name() -> str: 6 | """Returns name of OS""" 7 | return platform.system().lower() 8 | 9 | 10 | def get_home() -> str: 11 | """Returns path to ~""" 12 | return os.path.expanduser("~") 13 | 14 | 15 | def get_config_paths(): 16 | """Returns a dict of config paths for the correct OS.""" 17 | if get_os_name() == "darwin": 18 | sublime2_path = os.path.join( 19 | get_home(), "Library/Application Support/Sublime Text 2" 20 | ) 21 | sublime3_path = os.path.join( 22 | get_home(), "Library/Application Support/Sublime Text 3" 23 | ) 24 | vscode_path_1 = os.path.join( 25 | get_home(), "Library/Application Support/Code/User/settings.json" 26 | ) 27 | vscode_path_2 = os.path.join( 28 | get_home(), "Library/Application Support/Code/User/Snippets" 29 | ) 30 | vscode_path_3 = os.path.join( 31 | get_home(), "Library/Application Support/Code/User/keybindings.json" 32 | ) 33 | terminal_path = os.path.join( 34 | get_home(), "Library/Preferences/com.apple.Terminal.plist" 35 | ) 36 | 37 | return { 38 | sublime2_path: "sublime2", 39 | sublime3_path: "sublime3", 40 | vscode_path_1: "vscode/settings", 41 | vscode_path_2: "vscode/Snippets", 42 | vscode_path_3: "vscode/keybindings", 43 | terminal_path: "terminal_plist", 44 | } 45 | else: # Linux paths 46 | sublime2_path = "/.config/sublime-text-2" 47 | sublime3_path = "/.config/sublime-text-3" 48 | vscode_path_1 = "/.config/Code/User/settings.json" 49 | vscode_path_2 = "/.config/Code/User/Snippets" 50 | vscode_path_3 = "/.config/Code/User/keybindings.json" 51 | return { 52 | # TODO: Double check these paths. Not sure these are right. 53 | sublime2_path: "sublime2", 54 | sublime3_path: "sublime3", 55 | vscode_path_1: "vscode/settings", 56 | vscode_path_2: "vscode/Snippets", 57 | vscode_path_3: "vscode/keybindings", 58 | } 59 | 60 | 61 | def get_fonts_dir() -> str: 62 | """Returns default path to fonts on the current platform""" 63 | os_name = get_os_name() 64 | if os_name == "darwin": 65 | return os.path.join(get_home(), "Library/Fonts") 66 | elif os_name == "linux": 67 | return "/usr/local/share/fonts" 68 | 69 | 70 | def get_applications_dir() -> str: 71 | """Returns default path to applications directory""" 72 | os_name = get_os_name() 73 | if os_name == "darwin": 74 | return "/Applications" 75 | elif os_name == "linux": 76 | return "/usr/share/applications" 77 | -------------------------------------------------------------------------------- /shallow_backup/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import stat 5 | from os import path, environ, chmod 6 | from .printing import * 7 | from .compatibility import * 8 | from .utils import safe_mkdir, strip_home 9 | from .constants import ProjInfo 10 | from functools import lru_cache 11 | 12 | 13 | def get_xdg_config_path() -> str: 14 | """Returns path to $SHALLOW_BACKUP_CONFIG_DIR (if set), $XDG_CONFIG_HOME, or ~/.config if none of those exist.""" 15 | return environ.get("SHALLOW_BACKUP_CONFIG_DIR") or environ.get("XDG_CONFIG_HOME") or path.join(path.expanduser("~"), ".config") 16 | 17 | 18 | @lru_cache(maxsize=1) 19 | def get_config_path() -> str: 20 | """ 21 | Detects if in testing or prod env, and returns the right config path. 22 | :return: Path to config. 23 | """ 24 | test_config_path = environ.get("SHALLOW_BACKUP_TEST_CONFIG_PATH", None) 25 | legacy_config_path = path.join(get_xdg_config_path(), "shallow-backup.conf") 26 | new_config_path = path.join(get_xdg_config_path(), "shallow-backup.json") 27 | if test_config_path: 28 | return test_config_path 29 | elif path.exists(legacy_config_path): 30 | return legacy_config_path 31 | else: 32 | return new_config_path 33 | 34 | 35 | def get_config() -> dict: 36 | """ 37 | :return Config. 38 | """ 39 | config_path = get_config_path() 40 | with open(config_path) as file: 41 | try: 42 | config = json.load(file) 43 | except json.decoder.JSONDecodeError: 44 | print_red_bold(f"ERROR: Invalid syntax in {config_path}") 45 | sys.exit(1) 46 | return config 47 | 48 | 49 | def write_config(config) -> None: 50 | """ 51 | Write to config file 52 | """ 53 | with open(get_config_path(), "w") as file: 54 | json.dump(config, file, indent=4) 55 | 56 | 57 | def get_default_config() -> dict: 58 | """Returns a default, platform specific config.""" 59 | return { 60 | "backup_path": "~/shallow-backup", 61 | "dotfiles": { 62 | ".bash_profile": { 63 | "backup_condition": "", 64 | "reinstall_condition": "", 65 | }, 66 | ".bashrc": {}, 67 | ".config/git": {}, 68 | ".config/nvim/init.vim": {}, 69 | ".config/tmux": {}, 70 | ".config/zsh": {}, 71 | ".profile": {}, 72 | ".pypirc": {}, 73 | ".ssh": {}, 74 | ".zshenv": {}, 75 | f"{strip_home(get_config_path())}": {}, 76 | }, 77 | "root-gitignore": ["dotfiles/.ssh", "dotfiles/.pypirc", ".DS_Store"], 78 | "dotfiles-gitignore": [ 79 | ".ssh", 80 | ".pypirc", 81 | ".DS_Store", 82 | ], 83 | "config_mapping": get_config_paths(), 84 | "lowest_supported_version": ProjInfo.VERSION, 85 | } 86 | 87 | 88 | def safe_create_config() -> None: 89 | """ 90 | Creates config file (with 644 permissions) if it doesn't exist already. Prompts to update 91 | it if an outdated version is detected. 92 | """ 93 | backup_config_path = get_config_path() 94 | # If it doesn't exist, create it. 95 | if not os.path.exists(backup_config_path): 96 | print_path_blue("Creating config file at:", backup_config_path) 97 | backup_config = get_default_config() 98 | safe_mkdir(os.path.split(backup_config_path)[0]) 99 | write_config(backup_config) 100 | # $ chmod 644 config_file 101 | chmod( 102 | get_config_path(), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH 103 | ) 104 | 105 | 106 | def check_insecure_config_permissions() -> bool: 107 | """Checks to see if group/others can write to config file. 108 | Returns: True if they can, False otherwise.""" 109 | config_path = get_config_path() 110 | mode = os.stat(config_path).st_mode 111 | if mode & stat.S_IWOTH or mode & stat.S_IWGRP: 112 | print_red_bold( 113 | f"WARNING: {config_path} is writable by group/others and vulnerable to attack. To resolve, run: \n\t$ chmod 644 {config_path}" 114 | ) 115 | return True 116 | else: 117 | return False 118 | 119 | 120 | def delete_config_file() -> None: 121 | """Delete config file.""" 122 | config_path = get_config_path() 123 | if os.path.isfile(config_path): 124 | print_red_bold("Deleting config file.") 125 | os.remove(config_path) 126 | else: 127 | print_red_bold("ERROR: No config file found.") 128 | 129 | 130 | def add_dot_path_to_config(backup_config: dict, file_path: str) -> dict: 131 | """ 132 | Add dotfile to config with default reinstall and backup conditions. 133 | Exit if the file_path parameter is invalid. 134 | :backup_config: dict representing current config 135 | :file_path: str relative or absolute path of file to add to config 136 | :return new backup config 137 | """ 138 | abs_path = path.abspath(file_path) 139 | if not path.exists(abs_path): 140 | print_path_red("Invalid file path:", abs_path) 141 | return backup_config 142 | else: 143 | stripped_home_path = strip_home(abs_path) 144 | print_path_blue("Added:", stripped_home_path) 145 | backup_config["dotfiles"][stripped_home_path] = {} 146 | return backup_config 147 | 148 | 149 | def edit_config(): 150 | """ 151 | Open the config in the default editor 152 | """ 153 | config_path = get_config_path() 154 | editor = os.environ.get("EDITOR", "vim") 155 | os.system(f"{editor} {config_path}") 156 | -------------------------------------------------------------------------------- /shallow_backup/constants.py: -------------------------------------------------------------------------------- 1 | class ProjInfo: 2 | PROJECT_NAME = "shallow-backup" 3 | VERSION = "6.4" 4 | AUTHOR_GITHUB = "alichtman" 5 | AUTHOR_FULL_NAME = "Aaron Lichtman" 6 | DESCRIPTION = ( 7 | "Easily create lightweight backups of installed packages, dotfiles, and more." 8 | ) 9 | URL = "https://github.com/alichtman/shallow-backup" 10 | BUG_REPORT_URL = "https://github.com/alichtman/shallow-backup/issues" 11 | AUTHOR_EMAIL = "aaronlichtman@gmail.com" 12 | LOGO = """ 13 | dP dP dP dP dP 14 | 88 88 88 88 88 15 | ,d8888' 88d888b. .d8888b. 88 88 .d8888b. dP dP dP 88d888b. .d8888b. .d8888b. 88 .dP dP dP 88d888b. 16 | Y8ooooo, 88' `88 88' `88 88 88 88' `88 88 88 88 88' `88 88' `88 88' `\"\" 88888\" 88 88 88' `88 17 | 88 88 88 88. .88 88 88 88. .88 88.88b.88' 88. .88 88. .88 88. ... 88 `8b. 88. .88 88. .88 18 | `88888P' dP dP `88888P8 dP dP `88888P' 8888P Y8P 88Y8888' `88888P8 `88888P' dP `YP `88888P' 88Y888P' 19 | 88 20 | dP """ 21 | 22 | 23 | ProjInfo = ProjInfo() 24 | -------------------------------------------------------------------------------- /shallow_backup/git_wrapper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import readline # Imported to support arrow key navigation during input 3 | import subprocess 4 | import sys 5 | from difflib import unified_diff 6 | from pathlib import Path 7 | from shutil import move, which 8 | 9 | import git 10 | from colorama import Fore 11 | from git import GitCommandError 12 | 13 | from .config import get_config 14 | from .printing import ( 15 | print_blue_bold, 16 | print_error_report_github_issue_and_exit, 17 | print_green_bold, 18 | print_path_red, 19 | print_path_yellow, 20 | print_red_bold, 21 | print_yellow, 22 | print_yellow_bold, 23 | prompt_yes_no, 24 | ) 25 | from .utils import safe_mkdir 26 | 27 | ########### 28 | # FUNCTIONS 29 | ########### 30 | 31 | 32 | def color_diff(diff): 33 | """Colorizes the diff output. https://chezsoi.org/lucas/blog/colored-diff-output-with-python.html""" 34 | for line in diff: 35 | if line.startswith("+"): 36 | yield Fore.GREEN + line + Fore.RESET 37 | elif line.startswith("-"): 38 | yield Fore.RED + line + Fore.RESET 39 | elif line.startswith("^"): 40 | yield Fore.BLUE + line + Fore.RESET 41 | else: 42 | yield line 43 | 44 | 45 | def git_set_remote(repo, remote_url): 46 | """ 47 | Sets git repo upstream URL and fast-forwards history. 48 | """ 49 | print_path_yellow("Setting remote URL to:", "{}...".format(remote_url)) 50 | 51 | try: 52 | origin = repo.create_remote("origin", remote_url) 53 | origin.fetch() 54 | except git.CommandError: 55 | print_yellow_bold("Updating existing remote URL...") 56 | repo.delete_remote(repo.remotes.origin) 57 | origin = repo.create_remote("origin", remote_url) 58 | origin.fetch() 59 | 60 | 61 | def create_gitignore(dir_path, key: str): 62 | """ 63 | Creates a .gitignore file that ignores all files listed in config. 64 | Handles backwards compatibility for the default-gitignore -> root-gitignore 65 | change and the introduction of the dotfiles-gitignore key in v4.0. 66 | """ 67 | safe_mkdir(dir_path) 68 | gitignore_path = os.path.join(dir_path, ".gitignore") 69 | print_yellow_bold( 70 | f"Updating .gitignore file at {gitignore_path} with config from {key}" 71 | ) 72 | try: 73 | files_to_ignore = get_config()[key] 74 | except KeyError: 75 | if key == "root-gitignore": 76 | files_to_ignore = get_config()["default-gitignore"] 77 | elif key == "dotfiles-gitignore": 78 | files_to_ignore = [] 79 | else: 80 | print_error_report_github_issue_and_exit() 81 | # This next line will never be hit, but it is here to silence the lint about files_to_ignore possibly being unset. 82 | sys.exit(1) 83 | with open(os.path.join(dir_path, ".gitignore"), "w+") as f: 84 | for ignore in files_to_ignore: 85 | f.write("{}\n".format(ignore)) 86 | 87 | 88 | def safe_git_init(dir_path) -> tuple[git.Repo, bool]: 89 | """ 90 | If there is no git repo inside the dir_path, initialize one. 91 | Returns tuple of (git.Repo, bool new_git_repo_created) 92 | """ 93 | if not os.path.isdir(os.path.join(dir_path, ".git")): 94 | print_yellow_bold("Initializing new git repo...") 95 | try: 96 | repo = git.Repo.init(dir_path) 97 | return repo, True 98 | except GitCommandError: 99 | print_red_bold( 100 | "ERROR: We ran into some trouble creating the git repo. Double check that you have write permissions." 101 | ) 102 | sys.exit(1) 103 | else: 104 | print_yellow_bold("Detected git repo.") 105 | repo = git.Repo(dir_path) 106 | return repo, False 107 | 108 | 109 | def handle_separate_git_dir_in_dotfiles(dotfiles_path: Path, dry_run: bool = False): 110 | print_yellow_bold("Checking for separate git repo in dotfiles directory...") 111 | if ".git" in os.listdir(dotfiles_path): 112 | dotfiles_repo = git.Repo(dotfiles_path) 113 | if dotfiles_repo.git.status("--porcelain"): 114 | print_green_bold("Detected a nested dotfiles repo that is dirty!!") 115 | print_green_bold( 116 | "Do you want to create and push a commit in this repo first, before dealing with the parent?" 117 | ) 118 | if prompt_yes_no( 119 | "If you do not, the parent repo will not be able to commit the dotfile changes (due to a dirty submodule)", 120 | Fore.YELLOW, 121 | ): 122 | print_green_bold("Okay, switching into dotfiles subrepo...") 123 | git_add_all_commit_push( 124 | dotfiles_repo, 125 | dry_run=dry_run, 126 | ) 127 | print_green_bold("Switching back to parent shallow-backup repo...") 128 | else: 129 | print_green_bold("Detected a nested dotfiles repo that is clean.") 130 | else: 131 | print_yellow_bold("No nested dotfiles repo detected.") 132 | 133 | 134 | def git_add_all_and_print_status(repo: git.Repo): 135 | print_yellow("Staging all files for commit...") 136 | repo.git.add(all=True) 137 | print_yellow_bold(f"Git status of {repo.working_dir}") 138 | print(repo.git.status()) 139 | 140 | 141 | def install_trufflehog_git_hook(repo: git.Repo): 142 | """ 143 | Make sure trufflehog and pre-commit are installed and on the PATH. Then register a pre-commit hook for the repo. 144 | """ 145 | 146 | trufflehog_hook_text = """repos: 147 | - repo: local 148 | hooks: 149 | - id: trufflehog 150 | name: TruffleHog 151 | description: Detect secrets in your data. 152 | entry: bash -c 'trufflehog git file://. --since-commit HEAD --fail --only-verified --no-update' 153 | language: system 154 | stages: ["commit", "push"] 155 | """ 156 | 157 | def update_precommit_file(): 158 | with open(precommit_file, "w+") as f: 159 | f.write(trufflehog_hook_text) 160 | 161 | pass 162 | 163 | if not which("trufflehog"): 164 | print_red_bold( 165 | "trufflehog (https://github.com/trufflesecurity/trufflehog) is not installed. Please install it to continue." 166 | ) 167 | sys.exit() 168 | if not which("pre-commit"): 169 | print_red_bold( 170 | "pre-commit (https://pre-commit.com/) is not installed. Please install it to continue." 171 | ) 172 | sys.exit() 173 | 174 | precommit_file = Path(repo.working_dir) / ".pre-commit-config.yaml" 175 | if not precommit_file.exists(): 176 | print_yellow_bold("Adding pre-commit config file...") 177 | update_precommit_file() 178 | else: 179 | # TODO: Add an update check opt out config option 180 | current_precommit_file_contents = precommit_file.read_text() 181 | if current_precommit_file_contents != trufflehog_hook_text: 182 | diff = unified_diff( 183 | current_precommit_file_contents.splitlines(), 184 | trufflehog_hook_text.splitlines(), 185 | lineterm="", 186 | ) 187 | 188 | colored_diff = "\n".join(color_diff(diff)) 189 | if colored_diff.strip() == "": 190 | print_yellow_bold( 191 | "Your pre-commit config file is not up to date, but the only difference is whitespace. Updating automatically." 192 | ) 193 | update_precommit_file() 194 | else: 195 | print_yellow_bold( 196 | "Your pre-commit config file is not up to date. Here is the diff:" 197 | ) 198 | print(colored_diff) 199 | 200 | if prompt_yes_no("Apply update?", Fore.YELLOW): 201 | print_yellow_bold("Updating pre-commit config file...") 202 | update_precommit_file() 203 | 204 | # Safe to run every time 205 | subprocess.call("pre-commit install", cwd=repo.working_dir, shell=True) 206 | 207 | 208 | def git_add_all_commit_push(repo: git.Repo, dry_run: bool = False): 209 | """ 210 | Stages all changed files in dir_path and its children folders for commit, 211 | commits them and pushes to a remote if it's configured. 212 | 213 | :param git.repo repo: The repo 214 | :param str message: The commit message 215 | """ 216 | install_trufflehog_git_hook(repo) 217 | if repo.is_dirty(): 218 | git_add_all_and_print_status(repo) 219 | if not prompt_yes_no( 220 | "Make a commit (with a trufflehog pre-commit hook)? Ctrl-C to exit", 221 | Fore.YELLOW, 222 | ): 223 | return 224 | if dry_run: 225 | print_yellow_bold("Dry run: Would have made a commit!") 226 | return 227 | print_yellow_bold("Making new commit...") 228 | process = subprocess.run(["git", "commit", "--verbose"], cwd=repo.working_dir) 229 | if process.returncode != 0: 230 | print_red_bold( 231 | "Failed to make a commit. The two most likely reasons for this are:\n\t1. No commit message was provided.\n\t2. trufflehog detected secrets in the commit.\nPlease resolve ths issue and try again." 232 | ) 233 | sys.exit(1) 234 | else: 235 | print_yellow_bold("Successful commit.") 236 | 237 | if prompt_yes_no( 238 | "Push commit to remote? Did you check for secrets carefully? trufflehog is not perfect...", 239 | Fore.YELLOW, 240 | ): 241 | if "origin" in [remote.name for remote in repo.remotes]: 242 | print_path_yellow( 243 | "Pushing to remote:", 244 | f"{repo.remotes.origin.url}[origin/{repo.active_branch.name}]...", 245 | ) 246 | repo.git.fetch() 247 | repo.git.push("--set-upstream", "origin", "HEAD") 248 | else: 249 | print_yellow_bold("No changes to commit...") 250 | 251 | 252 | def move_git_repo(source_path, dest_path): 253 | """ 254 | Moves git folder and .gitignore to the new backup directory. 255 | Exits if there is already a git repo in the directory. 256 | """ 257 | dest_git_dir = os.path.join(dest_path, ".git") 258 | dest_git_ignore = os.path.join(dest_path, ".gitignore") 259 | git_exists = os.path.exists(dest_git_dir) 260 | gitignore_exists = os.path.exists(dest_git_ignore) 261 | 262 | if git_exists or gitignore_exists: 263 | print_red_bold("Evidence of a git repo has been detected.") 264 | if git_exists: 265 | print_path_red("A git repo already exists here:", dest_git_dir) 266 | if gitignore_exists: 267 | print_path_red("A gitignore file already exists here:", dest_git_ignore) 268 | print_red_bold("Exiting to prevent accidental deletion of user data.") 269 | sys.exit(1) 270 | 271 | git_dir = os.path.join(source_path, ".git") 272 | git_ignore_file = os.path.join(source_path, ".gitignore") 273 | 274 | try: 275 | move(git_dir, dest_path) 276 | move(git_ignore_file, dest_path) 277 | print_blue_bold("Moving git repo to new location.") 278 | except FileNotFoundError: 279 | pass 280 | -------------------------------------------------------------------------------- /shallow_backup/printing.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import List, Set, Union 4 | 5 | import inquirer 6 | from colorama import Fore, Style 7 | 8 | from .constants import ProjInfo 9 | 10 | 11 | def print_blue(text): 12 | print(Fore.BLUE + text + Style.RESET_ALL) 13 | 14 | 15 | def print_red(text): 16 | print(Fore.RED + text + Style.RESET_ALL) 17 | 18 | 19 | def print_yellow(text): 20 | print(Fore.YELLOW + text + Style.RESET_ALL) 21 | 22 | 23 | def print_green(text): 24 | print(Fore.GREEN + text + Style.RESET_ALL) 25 | 26 | 27 | def print_blue_bold(text): 28 | print(Fore.BLUE + Style.BRIGHT + text + Style.RESET_ALL) 29 | 30 | 31 | def print_red_bold(text): 32 | print(Fore.RED + Style.BRIGHT + text + Style.RESET_ALL) 33 | 34 | 35 | def print_yellow_bold(text): 36 | print(Fore.YELLOW + Style.BRIGHT + text + Style.RESET_ALL) 37 | 38 | 39 | def print_green_bold(text): 40 | print(Fore.GREEN + Style.BRIGHT + text + Style.RESET_ALL) 41 | 42 | 43 | def print_path_blue(text, path): 44 | print(Fore.BLUE + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL) 45 | 46 | 47 | def print_path_red(text, path): 48 | print(Fore.RED + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL) 49 | 50 | 51 | def print_path_yellow(text, path): 52 | print(Fore.YELLOW + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL) 53 | 54 | 55 | def print_path_green(text, path): 56 | print(Fore.GREEN + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL) 57 | 58 | 59 | def print_list_pretty( 60 | items_to_print: Union[Set, List], style=Style.BRIGHT, color=Fore.RED 61 | ): 62 | print(f"{style}{color}") 63 | for x in items_to_print: 64 | print(f" - {x}") 65 | print(Style.RESET_ALL) 66 | 67 | 68 | def print_dry_run_copy_info(source, dest): 69 | """Show source -> dest copy. Replaces expanded ~ with ~ if it's at the beginning of paths. 70 | source and dest are trimmed in the middle if needed. Removed characters will be replaced by ... 71 | :param source: Can be of type str or Path 72 | :param dest: Can be of type str or Path 73 | """ 74 | 75 | def shorten_home(path): 76 | expanded_home = os.path.expanduser("~") 77 | path = str(path) 78 | if path.startswith(expanded_home): 79 | return path.replace(expanded_home, "~") 80 | return path 81 | 82 | def truncate_middle(path: str, acceptable_len: int): 83 | """Middle truncate a string 84 | https://www.xormedia.com/string-truncate-middle-with-ellipsis/ 85 | """ 86 | if len(path) <= acceptable_len: 87 | return path 88 | # half of the size, minus the 3 .'s 89 | n_2 = int(acceptable_len / 2 - 3) 90 | # whatever's left 91 | n_1 = int(acceptable_len - n_2 - 3) 92 | return f"{path[:n_1]}...{path[-n_2:]}" 93 | 94 | trimmed_source = shorten_home(source) 95 | trimmed_dest = shorten_home(dest) 96 | longest_allowed_path_len = 87 97 | if len(trimmed_source) + len(trimmed_dest) > longest_allowed_path_len: 98 | trimmed_source = truncate_middle(trimmed_source, longest_allowed_path_len) 99 | trimmed_dest = truncate_middle(trimmed_dest, longest_allowed_path_len) 100 | print( 101 | Fore.YELLOW + Style.BRIGHT + trimmed_source + Style.NORMAL, 102 | "->", 103 | Style.BRIGHT + trimmed_dest + Style.RESET_ALL, 104 | ) 105 | 106 | 107 | def print_version_info(cli=True): 108 | """ 109 | Formats version differently for CLI and splash screen. 110 | """ 111 | version = "v{} by {} (@{})".format( 112 | ProjInfo.VERSION, ProjInfo.AUTHOR_FULL_NAME, ProjInfo.AUTHOR_GITHUB 113 | ) 114 | if not cli: 115 | print(Fore.RED + Style.BRIGHT + "\t{}\n".format(version) + Style.RESET_ALL) 116 | else: 117 | print(version) 118 | 119 | 120 | def splash_screen(): 121 | """Display splash graphic, and then stylized version and author info.""" 122 | print(Fore.YELLOW + Style.BRIGHT + "\n" + ProjInfo.LOGO + Style.RESET_ALL) 123 | print_version_info(False) 124 | 125 | 126 | def print_section_header(title, color): 127 | """Prints variable sized section header.""" 128 | block = "#" * (len(title) + 2) 129 | print("\n" + color + Style.BRIGHT + block) 130 | print("#", title) 131 | print(block + "\n" + Style.RESET_ALL) 132 | 133 | 134 | def print_pkg_mgr_backup(mgr): 135 | print( 136 | "{}Backing up {}{}{}{}{} packages list...{}".format( 137 | Fore.BLUE, 138 | Style.BRIGHT, 139 | Fore.YELLOW, 140 | mgr, 141 | Fore.BLUE, 142 | Style.NORMAL, 143 | Style.RESET_ALL, 144 | ) 145 | ) 146 | 147 | 148 | def print_pkg_mgr_reinstall(mgr): 149 | print( 150 | "{}Reinstalling {}{}{}{}{}...{}".format( 151 | Fore.BLUE, 152 | Style.BRIGHT, 153 | Fore.YELLOW, 154 | mgr, 155 | Fore.BLUE, 156 | Style.NORMAL, 157 | Style.RESET_ALL, 158 | ) 159 | ) 160 | 161 | 162 | # TODO: BUG: Why does moving this to prompts.py cause circular imports? 163 | def prompt_yes_no(message, color, invert=False) -> bool: 164 | """ 165 | Print question and return True or False depending on user selection from list. 166 | """ 167 | questions = [ 168 | inquirer.List( 169 | "choice", 170 | message=color + Style.BRIGHT + message + Fore.BLUE, 171 | choices=(" No", " Yes") if invert else (" Yes", " No"), 172 | ) 173 | ] 174 | 175 | answers = inquirer.prompt(questions) 176 | if answers: 177 | return answers.get("choice").strip().lower() == "yes" 178 | else: 179 | sys.exit(1) 180 | 181 | 182 | def print_error_report_github_issue_and_exit(): 183 | """Prints a clickable link to file a new github issue and exits with error code 1.""" 184 | print_red_bold( 185 | "Please open a new issue at https://github.com/alichtman/shallow-backup/issues/new" 186 | ) 187 | sys.exit(1) 188 | -------------------------------------------------------------------------------- /shallow_backup/prompts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import inquirer 4 | import readline 5 | from .utils import * 6 | from .printing import * 7 | from .config import * 8 | from .git_wrapper import git_set_remote, move_git_repo 9 | from .utils import check_if_path_is_valid_dir 10 | 11 | 12 | def path_update_prompt(config): 13 | """ 14 | Ask user if they'd like to update the backup path or not. 15 | If yes, update. If no... don't. 16 | """ 17 | current_path = config["backup_path"] 18 | print_path_blue("Current shallow-backup path:", current_path) 19 | if prompt_yes_no( 20 | "Would you like to move this somewhere else?", Fore.GREEN, invert=True 21 | ): 22 | while True: 23 | print_green_bold("Enter relative or absolute path:") 24 | abs_path = expand_to_abs_path(input()) 25 | 26 | if not check_if_path_is_valid_dir(abs_path): 27 | continue 28 | 29 | print_path_blue("\nUpdating shallow-backup path to:", abs_path) 30 | mkdir_warn_overwrite(abs_path) 31 | move_git_repo(current_path, abs_path) 32 | config["backup_path"] = abs_path 33 | write_config(config) 34 | return 35 | 36 | 37 | def git_url_prompt(repo): 38 | """ 39 | Ask user if they'd like to add a remote URL to their git repo. 40 | If yes, do it. 41 | """ 42 | print_red_bold( 43 | "WARNING: If you back up to a public remote, make sure no sensitive files are included by modifying the .gitignore." 44 | ) 45 | if prompt_yes_no( 46 | "Would you like to set a remote URL for this git repo?", Fore.GREEN 47 | ): 48 | print_green_bold("Enter URL:") 49 | remote_url = input() 50 | git_set_remote(repo, remote_url) 51 | 52 | 53 | def add_to_config_prompt(): 54 | """ 55 | Prompt sequence for a user to add a path to the config file under 56 | either the dot or config sections. 57 | """ 58 | add_prompt = [ 59 | inquirer.List( 60 | "choice", 61 | message=Fore.GREEN 62 | + Style.BRIGHT 63 | + "Which section would you like to add this to?" 64 | + Fore.BLUE, 65 | choices=[ 66 | " Dots", 67 | " Configs", 68 | ], 69 | ) 70 | ] 71 | 72 | section = inquirer.prompt(add_prompt).get("choice").strip().lower() 73 | config = get_config() 74 | 75 | # Prompt until we get a valid path. 76 | while True: 77 | print_green_bold("Enter a path to add to {}:".format(section)) 78 | expanded_path = expand_to_abs_path(input()) 79 | split_path = expanded_path.split("/") 80 | 81 | # Check if path exists. 82 | if not os.path.exists(expanded_path): 83 | print_red_bold("ERR: {} doesn't exist.".format(expanded_path)) 84 | continue 85 | 86 | config_key = None 87 | if section == "dots": 88 | # Make sure it's actually a dotfile 89 | if split_path[-1][0] != ".": 90 | print_red_bold("ERR: Not a dotfile.") 91 | continue 92 | 93 | # Determine if adding to dotfiles or dotfolders 94 | if not os.path.isdir(expanded_path): 95 | config_key = "dotfiles" 96 | print_blue_bold("Adding {} to dotfile backup.".format(expanded_path)) 97 | else: 98 | config_key = "dotfolders" 99 | print_blue_bold("Adding {} to dotfolder backup.".format(expanded_path)) 100 | 101 | # Add path to config ensuring no duplicates. 102 | updated_config_key = set(config[config_key] + [path]) 103 | config[config_key] = list(updated_config_key) 104 | write_config(config) 105 | break 106 | 107 | elif section == "config": 108 | # Prompt for folder name 109 | print_green_bold("Enter a name for this config:") 110 | dir_name = input() 111 | config_key = "config_mapping" 112 | to_add_to_cfg = (expanded_path, dir_name) 113 | print_blue_bold("Adding {} to config backup.".format(expanded_path)) 114 | 115 | # Get dictionary of {path_to_backup: dest, ...} 116 | config_path_dict = config[config_key] 117 | config_path_dict[to_add_to_cfg[0]] = to_add_to_cfg[1] 118 | config[config_key] = config_path_dict 119 | write_config(config) 120 | break 121 | 122 | 123 | def remove_from_config_prompt(): 124 | """ 125 | Sequence of prompts for a user to remove a path from the config. 126 | 2-layer selection screen. First screen is for choosing dot or 127 | config, and then next selection is for the specific path. 128 | """ 129 | # Get section to display. 130 | section_prompt = [ 131 | inquirer.List( 132 | "choice", 133 | message=Fore.GREEN 134 | + Style.BRIGHT 135 | + "Which section would you like to remove a path from?" 136 | + Fore.BLUE, 137 | choices=[" Dotfiles", " Dotfolders", " Configs"], 138 | ) 139 | ] 140 | 141 | config = get_config() 142 | section = inquirer.prompt(section_prompt).get("choice").strip().lower() 143 | if section == "configs": 144 | section = "config_mapping" 145 | paths = config[section] 146 | # Get only backup paths, not dest paths if it's a dictionary. 147 | if isinstance(paths, dict): 148 | paths = list(paths.keys()) 149 | 150 | path_prompt = [ 151 | inquirer.List( 152 | "choice", 153 | message=Fore.GREEN + Style.BRIGHT + "Select a path to remove." + Fore.BLUE, 154 | choices=paths, 155 | ) 156 | ] 157 | path_to_remove = inquirer.prompt(path_prompt).get("choice") 158 | print_blue_bold("Removing {} from backup...".format(path_to_remove)) 159 | paths.remove(path_to_remove) 160 | config[section] = paths 161 | write_config(config) 162 | 163 | 164 | def main_menu_prompt(): 165 | """ 166 | Prompt user for an action. 167 | """ 168 | questions = [ 169 | inquirer.List( 170 | "choice", 171 | message=Fore.GREEN 172 | + Style.BRIGHT 173 | + "What would you like to do?" 174 | + Fore.BLUE, 175 | choices=[ 176 | " Back up all", 177 | " Back up configs", 178 | " Back up dotfiles", 179 | " Back up fonts", 180 | " Back up packages", 181 | " Reinstall all", 182 | " Reinstall configs", 183 | " Reinstall dotfiles", 184 | " Reinstall fonts", 185 | " Reinstall packages", 186 | " Add path to config", 187 | " Remove path from config", 188 | " Edit config", 189 | " Destroy backup", 190 | ], 191 | ), 192 | ] 193 | 194 | answers = inquirer.prompt(questions) 195 | 196 | if answers: 197 | choice = answers.get("choice") 198 | if choice: 199 | return choice.strip().lower() 200 | 201 | raise Exception("ERR: Invalid choice.") 202 | -------------------------------------------------------------------------------- /shallow_backup/reinstall.py: -------------------------------------------------------------------------------- 1 | import os 2 | from shlex import quote 3 | from .utils import ( 4 | run_cmd, 5 | get_abs_path_subfiles, 6 | exit_if_dir_is_empty, 7 | safe_mkdir, 8 | evaluate_condition, 9 | find_path_for_permission_error_reporting, 10 | ) 11 | from .printing import * 12 | from colorama import Fore, Style 13 | from .compatibility import * 14 | from .config import get_config 15 | from pathlib import Path, PurePath 16 | from shutil import copytree, copyfile, copy 17 | 18 | # NOTE: Naming convention is like this since the CLI flags would otherwise 19 | # conflict with the function names. 20 | 21 | 22 | def reinstall_dots_sb( 23 | dots_path: str, home_path: str = os.path.expanduser("~"), dry_run: bool = False 24 | ): 25 | """Reinstall all dotfiles and folders by copying them from dots_path 26 | to a path relative to home_path, or to an absolute path.""" 27 | exit_if_dir_is_empty(dots_path, "dotfile") 28 | print_section_header("REINSTALLING DOTFILES", Fore.BLUE) 29 | 30 | # Get paths of ALL files that we will be reinstalling from config. 31 | # If .ssh is in the config, full paths of all dots_path/.ssh/* files 32 | # will be in dotfiles_to_reinstall 33 | config = get_config()["dotfiles"] 34 | 35 | dotfiles_to_reinstall = [] 36 | for dotfile_path_from_config, options in config.items(): 37 | # Evaluate condition, if specified. Skip if the command doesn't return true. 38 | condition_success = evaluate_condition( 39 | condition=options.get("reinstall_condition", ""), 40 | backup_or_reinstall="reinstall", 41 | dotfile_path=dotfile_path_from_config, 42 | ) 43 | if not condition_success: 44 | continue 45 | 46 | if dotfile_path_from_config.startswith("/"): 47 | dotfile_path_from_config = ":" + dotfile_path_from_config[1:] 48 | 49 | real_path_dotfile = os.path.join(dots_path, dotfile_path_from_config) 50 | if os.path.isfile(real_path_dotfile): 51 | dotfiles_to_reinstall.append(real_path_dotfile) 52 | else: 53 | subfiles_to_add = get_abs_path_subfiles(real_path_dotfile) 54 | dotfiles_to_reinstall.extend(subfiles_to_add) 55 | 56 | reinstallation_error_count = 0 57 | # Create list of tuples containing source and dest paths for dotfile reinstallation 58 | # The absolute file paths prepended with ':' are converted back to valid paths 59 | # Format: [(source, dest), ... ] 60 | full_path_dotfiles_to_reinstall = [] 61 | for source in dotfiles_to_reinstall: 62 | # If it's an absolute path, dest is the corrected path 63 | abs_path_start = os.path.join(dots_path, ":") 64 | if source.startswith(abs_path_start): 65 | dest = "/" + source[len(abs_path_start) :] 66 | else: 67 | # Otherwise, it should go in a path relative to the home path 68 | dest = source.replace(dots_path, home_path + "/") 69 | full_path_dotfiles_to_reinstall.append((Path(source), Path(dest))) 70 | 71 | files_with_permission_errors = set() 72 | # Copy files from backup to system 73 | for dot_source, dot_dest in full_path_dotfiles_to_reinstall: 74 | if dry_run: 75 | print_dry_run_copy_info(dot_source, dot_dest) 76 | continue 77 | 78 | # Create dest parent dir if it doesn't exist 79 | # One case that this can fail is if dot_dest.parent is a FILE. We will try-catch this case specifically. 80 | # https://github.com/alichtman/shallow-backup/issues/343#issuecomment-2120024456 81 | parent_dir = dot_dest.parent 82 | if os.path.isfile(parent_dir): 83 | print( 84 | f"{Fore.RED}{Style.BRIGHT}ERROR: {Style.NORMAL}{parent_dir}{Style.BRIGHT} is a file, however, this reinstallation process attempts to create {Style.NORMAL}{dot_dest}{Style.BRIGHT}, which would use that path as a directory. You will have to manually remediate this issue (likely by renaming or moving {Style.NORMAL}{dot_dest.parent}{Style.BRIGHT}){Style.NORMAL}{Style.RESET_ALL}" 85 | ) 86 | reinstallation_error_count += 1 87 | continue 88 | 89 | safe_mkdir(dot_dest.parent) 90 | try: 91 | copy(dot_source, dot_dest) 92 | except PermissionError as err: 93 | files_with_permission_errors.add( 94 | find_path_for_permission_error_reporting(err.filename) 95 | ) 96 | except FileNotFoundError as err: 97 | print_red_bold(f"ERROR: {err}") 98 | 99 | if reinstallation_error_count != 0: 100 | print_red_bold(f"\nSome errors which require manual resolution detected.") 101 | 102 | num_permission_errors = len(files_with_permission_errors) 103 | if num_permission_errors != 0: 104 | print_red_bold( 105 | f"\n{num_permission_errors} permission errors detected. Most of the time, this is not a problem.\nGit repos will have some read-only files, and will prevent you from writing to them without using sudo.\nAdditionally, some package managers (like zcomet, etc) make their install files read-only.\nYou should update these files using the respective tools that created them.\nThe following paths were problematic:" 106 | ) 107 | print_list_pretty(sorted(files_with_permission_errors)) 108 | 109 | print_section_header("DOTFILE REINSTALLATION COMPLETED", Fore.BLUE) 110 | 111 | 112 | def reinstall_fonts_sb(fonts_path: str, dry_run: bool = False): 113 | """Reinstall all fonts.""" 114 | exit_if_dir_is_empty(fonts_path, "font") 115 | print_section_header("REINSTALLING FONTS", Fore.BLUE) 116 | 117 | # Copy every file in fonts_path to ~/Library/Fonts 118 | for font in get_abs_path_subfiles(fonts_path): 119 | fonts_dir = get_fonts_dir() 120 | dest_path = quote(os.path.join(fonts_dir, font.split("/")[-1])) 121 | if dry_run: 122 | print_dry_run_copy_info(font, dest_path) 123 | continue 124 | copyfile(quote(font), quote(dest_path)) 125 | print_section_header("FONT REINSTALLATION COMPLETED", Fore.BLUE) 126 | 127 | 128 | def reinstall_configs_sb(configs_path: str, dry_run: bool = False): 129 | """Reinstall all configs from the backup.""" 130 | exit_if_dir_is_empty(configs_path, "config") 131 | print_section_header("REINSTALLING CONFIG FILES", Fore.BLUE) 132 | 133 | config = get_config() 134 | for dest_path, backup_loc in config["config_mapping"].items(): 135 | dest_path = quote(dest_path) 136 | source_path = quote(os.path.join(configs_path, backup_loc)) 137 | 138 | if dry_run: 139 | print_dry_run_copy_info(source_path, dest_path) 140 | continue 141 | 142 | if os.path.isdir(source_path): 143 | copytree(source_path, dest_path) 144 | elif os.path.isfile(source_path): 145 | copyfile(source_path, dest_path) 146 | 147 | print_section_header("CONFIG REINSTALLATION COMPLETED", Fore.BLUE) 148 | 149 | 150 | def reinstall_packages_sb(packages_path: str, dry_run: bool = False): 151 | """Reinstall all packages from the files in backup/installs.""" 152 | 153 | def run_cmd_if_no_dry_run(command, dry_run) -> int: 154 | if dry_run: 155 | print_yellow_bold(f"$ {command}") 156 | # Return 0 for any processes depending on chained successful commands 157 | return 0 158 | else: 159 | return run_cmd(command) 160 | 161 | exit_if_dir_is_empty(packages_path, "package") 162 | print_section_header("REINSTALLING PACKAGES", Fore.BLUE) 163 | 164 | # Figure out which install lists they have saved 165 | package_mgrs = set() 166 | for file in os.listdir(packages_path): 167 | manager = file.split("_")[0].replace("-", " ") 168 | if manager in [ 169 | "gem", 170 | "cargo", 171 | "npm", 172 | "pip", 173 | "pip3", 174 | "brew", 175 | "vscode", 176 | "macports", 177 | ]: 178 | package_mgrs.add(file.split("_")[0]) 179 | 180 | print_blue_bold("Package Manager Backups Found:") 181 | for mgr in package_mgrs: 182 | print_yellow("\t{}".format(mgr)) 183 | print() 184 | 185 | # TODO: Multithreading for reinstallation. 186 | # Construct reinstallation commands and execute them 187 | for pm in package_mgrs: 188 | if pm == "brew": 189 | print_pkg_mgr_reinstall(pm) 190 | cmd = f"brew bundle install --no-lock --file {packages_path}/brew_list.txt" 191 | run_cmd_if_no_dry_run(cmd, dry_run) 192 | elif pm == "npm": 193 | print_pkg_mgr_reinstall(pm) 194 | cmd = f"cat {packages_path}/npm_list.txt | xargs npm install -g" 195 | run_cmd_if_no_dry_run(cmd, dry_run) 196 | elif pm == "pip": 197 | print_pkg_mgr_reinstall(pm) 198 | cmd = f"pip install -r {packages_path}/pip_list.txt" 199 | run_cmd_if_no_dry_run(cmd, dry_run) 200 | elif pm == "pip3": 201 | print_pkg_mgr_reinstall(pm) 202 | cmd = f"pip3 install -r {packages_path}/pip3_list.txt" 203 | run_cmd_if_no_dry_run(cmd, dry_run) 204 | elif pm == "vscode": 205 | print_pkg_mgr_reinstall(pm) 206 | with open(f"{packages_path}/vscode_list.txt", "r") as file: 207 | for package in file: 208 | cmd = f"code --install-extension {package}" 209 | run_cmd_if_no_dry_run(cmd, dry_run) 210 | elif pm == "macports": 211 | print_red_bold("WARNING: Macports reinstallation is not supported.") 212 | elif pm == "gem": 213 | print_pkg_mgr_reinstall(pm) 214 | cmd = f"cat {packages_path}/gem_list.txt | xargs -L 1 gem install" 215 | run_cmd_if_no_dry_run(cmd, dry_run) 216 | elif pm == "cargo": 217 | print_pkg_mgr_reinstall(pm) 218 | cmd = f"cat {packages_path}/cargo_list.txt | xargs -L 1 cargo install" 219 | run_cmd_if_no_dry_run(cmd, dry_run) 220 | 221 | print_section_header("PACKAGE REINSTALLATION COMPLETED", Fore.BLUE) 222 | 223 | 224 | def reinstall_all_sb( 225 | dotfiles_path: str, 226 | packages_path: str, 227 | fonts_path: str, 228 | configs_path: str, 229 | dry_run: bool = False, 230 | ): 231 | """Call all reinstallation methods.""" 232 | reinstall_dots_sb(dotfiles_path, dry_run=dry_run) 233 | reinstall_packages_sb(packages_path, dry_run=dry_run) 234 | reinstall_fonts_sb(fonts_path, dry_run=dry_run) 235 | reinstall_configs_sb(configs_path, dry_run=dry_run) 236 | -------------------------------------------------------------------------------- /shallow_backup/upgrade.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .config import get_config 3 | from .printing import print_red_bold, print_red 4 | from .constants import ProjInfo 5 | 6 | 7 | def check_if_config_upgrade_needed(): 8 | """Checks if a config is supported by the current version of shallow-backup""" 9 | config = get_config() 10 | # If this key is not in the config, that means the config was installed pre-v5.0.0a 11 | if "lowest_supported_version" not in config: 12 | print_red_bold( 13 | f"ERROR: Config version detected as incompatible with current shallow-backup version ({ProjInfo.VERSION})." 14 | ) 15 | print_red("There are two possible fixes.") 16 | print_red( 17 | "1. Backup your config file to another location and remove the original config." 18 | ) 19 | print_red("\tshallow-backup will recreate a compatible config on the next run.") 20 | print_red("\tYou can then add in your custom backup paths manually.") 21 | print_red("2. Manually upgrade the config.") 22 | print_red_bold( 23 | "Please downgrade to a version of shallow-backup before v5.0.0a if you do not want to upgrade your config." 24 | ) 25 | sys.exit() 26 | -------------------------------------------------------------------------------- /shallow_backup/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from colorama import Fore 4 | import subprocess as sp 5 | from shlex import split 6 | import shutil 7 | from shutil import rmtree, copytree, copyfile 8 | from pathlib import Path 9 | from re import fullmatch 10 | from typing import Union, List 11 | from .printing import ( 12 | print_path_red, 13 | print_red_bold, 14 | print_path_blue, 15 | print_red, 16 | print_blue, 17 | prompt_yes_no, 18 | ) 19 | 20 | 21 | def run_cmd(command: Union[str, List]): 22 | """ 23 | Wrapper on subprocess.run to handle shell commands as either a list of args 24 | or a single string. 25 | """ 26 | if not isinstance(command, list): 27 | command = split(command) 28 | output = None 29 | try: 30 | while "|" in command: 31 | index = command.index("|") 32 | first_command, command = command[:index], command[index + 1 :] 33 | output = sp.Popen( 34 | first_command, 35 | stdin=output.stdout if output else None, 36 | stdout=sp.PIPE, 37 | stderr=sp.DEVNULL, 38 | ) 39 | return sp.run( 40 | command, 41 | stdout=sp.PIPE, 42 | stdin=output.stdout if output else None, 43 | stderr=sp.DEVNULL, 44 | ) 45 | except FileNotFoundError: # If package manager is missing 46 | return None 47 | 48 | 49 | def run_cmd_write_stdout(command: str, filepath: str) -> int: 50 | """ 51 | Runs a command and then writes its stdout to a file. 52 | Returns 0 on success, and -1 on failure. 53 | :param: command str representing command to run 54 | :param: filepath str file to write command's stdout to 55 | """ 56 | process = run_cmd(command) 57 | if process and process.returncode == 0: 58 | with open(filepath, "w+") as f: 59 | f.write(process.stdout.decode("utf-8")) 60 | return 0 61 | else: 62 | print_path_red("An error occurred while running: $", command) 63 | return -1 64 | 65 | 66 | def run_cmd_return_bool(command: str) -> bool: 67 | """Run a bash command and return True if the exit code is 0, False otherwise""" 68 | return os.system(f"/bin/bash -c '{command}'") == 0 69 | 70 | 71 | def evaluate_condition( 72 | condition: str, backup_or_reinstall: str, dotfile_path: str 73 | ) -> bool: 74 | """Evaluates the condition, if it exists, in bash and returns True or False, while providing output 75 | detailing what is going on. 76 | :param condition: A string that will be evaluated by bash. 77 | :param backup_or_reinstall: The only valid inputs are: "backup" or "reinstall" 78 | :param dotfile_path: Path to dotfile (relative to $HOME, or absolute) for which the condition is being evaluated 79 | """ 80 | if condition: 81 | print_blue( 82 | f"\n{backup_or_reinstall.capitalize()} condition detected for {dotfile_path}." 83 | ) 84 | condition_success = run_cmd_return_bool(condition) 85 | if not condition_success: 86 | print_blue( 87 | f"SKIPPING {backup_or_reinstall.lower()} b/c this is false: $ {condition}" 88 | ) 89 | return False 90 | else: 91 | print_blue( 92 | f"NOT skipping {backup_or_reinstall.lower()} b/c this is true: $ {condition}" 93 | ) 94 | return True 95 | else: 96 | return True 97 | 98 | 99 | def check_if_path_is_valid_dir(abs_path): 100 | """Returns False is the path leads to a file, True otherwise.""" 101 | if os.path.isfile(abs_path): 102 | print_path_red("New path is a file:", abs_path) 103 | print_red_bold("Please enter a directory.\n") 104 | return False 105 | return True 106 | 107 | 108 | def safe_mkdir(directory): 109 | """Makes dir if it doesn't already exist, creating all intermediate directories.""" 110 | os.makedirs(directory, exist_ok=True) 111 | 112 | 113 | def mkdir_overwrite(path): 114 | """ 115 | Makes a new directory, destroying the contents of the dir at path, if it exits. 116 | Ensures .git and .gitignore files inside of directory are not delected. 117 | """ 118 | if os.path.isdir(path): 119 | dirs = [] 120 | files = [] 121 | for file in os.listdir(path): 122 | full_path = os.path.join(path, file) 123 | # Allow dotfiles to be a sub-repo, and protect img folder. 124 | if ( 125 | full_path.endswith(".git") 126 | or full_path.endswith(".gitignore") 127 | or full_path.endswith("README.md") 128 | or full_path.endswith("img") 129 | or full_path.endswith(".pre-commit-config.yaml") 130 | ): 131 | continue 132 | 133 | if os.path.isdir(full_path): 134 | dirs.append(full_path) 135 | else: 136 | files.append(full_path) 137 | 138 | for file in files: 139 | os.remove(file) 140 | 141 | for directory in dirs: 142 | rmtree(directory) 143 | else: 144 | os.makedirs(path) 145 | 146 | 147 | def mkdir_warn_overwrite(path): 148 | """ 149 | Make destination dir if path doesn't exist, confirm before overwriting if it does. 150 | """ 151 | subdirs = ["dotfiles", "packages", "fonts", "configs"] 152 | if os.path.exists(path) and path.split("/")[-1] in subdirs: 153 | print_path_red("Directory already exists:", path) 154 | if prompt_yes_no("Erase directory and make new back up?", Fore.RED): 155 | mkdir_overwrite(path) 156 | else: 157 | print_red_bold("Exiting to prevent accidental deletion of data.") 158 | sys.exit() 159 | elif not os.path.exists(path): 160 | os.makedirs(path) 161 | print_path_blue("Created directory:", path) 162 | 163 | 164 | def overwrite_dir_prompt_if_needed(path: str, no_confirm: bool): 165 | """ 166 | Prompts the user before deleting the directory if needed. 167 | This function lets the CLI args silence the prompts. 168 | :param path: absolute path 169 | :param no_confirm: Flag that determines if user confirmation is needed. 170 | """ 171 | if no_confirm: 172 | mkdir_overwrite(path) 173 | else: 174 | mkdir_warn_overwrite(path) 175 | 176 | 177 | def exit_if_dir_is_empty(backup_path: str, backup_type: str): 178 | """Exit if the backup_path is not a directory or contains no files.""" 179 | if not os.path.isdir(backup_path) or not os.listdir(backup_path): 180 | print_red_bold("No {} backup found.".format(backup_type)) 181 | sys.exit(1) 182 | 183 | 184 | def destroy_backup_dir(backup_path): 185 | """Deletes the backup directory and its content""" 186 | try: 187 | print_path_red("Deleting backup directory:", backup_path) 188 | rmtree(backup_path) 189 | except OSError as e: 190 | print_red_bold("Error: {} - {}".format(e.filename, e.strerror)) 191 | 192 | 193 | def get_abs_path_subfiles(directory: str) -> list: 194 | """Returns list of absolute paths of files and folders contained in a directory, 195 | :param directory: Absolute path to directory to search 196 | """ 197 | file_paths = [] 198 | for path, _, files in os.walk(directory): 199 | for name in files: 200 | full_path = os.path.join(path, name) 201 | file_paths.append(full_path) 202 | return file_paths 203 | 204 | 205 | def copyfile_with_exception_handler(src, dst): 206 | try: 207 | copyfile(src, dst) 208 | except Exception: 209 | print_path_red("Error copying:", src) 210 | print_red(" -> This may mean you have an error in your config.") 211 | 212 | 213 | def copy_dir_if_valid(source_dir, backup_path): 214 | """ 215 | Copy dir from source_dir to backup_path. Skips copying if any of the 216 | 'invalid' directories appear anywhere in the source_dir path. 217 | """ 218 | invalid = {".Trash", ".npm", ".cache", ".rvm"} 219 | if invalid.intersection(set(os.path.split(source_dir))) != set(): 220 | return 221 | try: 222 | copytree(source_dir, backup_path, symlinks=False) 223 | except shutil.Error: 224 | print_path_red("Error copying:", source_dir) 225 | 226 | 227 | def home_prefix(path): 228 | """ 229 | Appends the path to the user's home path. 230 | :param path: Path to be appended. 231 | :return: (str) ~/path 232 | """ 233 | home_path = os.path.expanduser("~") 234 | return os.path.join(home_path, path) 235 | 236 | 237 | def expand_to_abs_path(path): 238 | """ 239 | Expands relative and user's home paths to the respective absolute path. Environment 240 | variables found on the input path will also be expanded. 241 | :param path: Path to be expanded. 242 | :return: (str) The absolute path. 243 | """ 244 | expanded_path = os.path.expanduser(path) 245 | expanded_path = os.path.expandvars(expanded_path) 246 | return os.path.abspath(expanded_path) 247 | 248 | 249 | def strip_home(full_path): 250 | """Removes the path to $HOME from the front of the absolute path, if it's there""" 251 | home_path = os.path.expanduser("~") 252 | if full_path.startswith(home_path): 253 | return full_path.replace(home_path + "/", "") 254 | else: 255 | return full_path 256 | 257 | 258 | def find_path_for_permission_error_reporting(path_maybe_containing_git_dir: str): 259 | """Given a path containing a git directory, return the path to the git dir. 260 | This will be used to create a set of git dirs we ran into errors while reinstalling. 261 | ~/.config/zsh/.zinit/plugins/changyuheng---zsh-interactive-cd/.git/objects/62/5373abf600839f2fdcd5c6d13184a1fe6dc708 262 | will turn into 263 | ~/.config/zsh/.zinit/plugins/changyuheng---zsh-interactive-cd/.git""" 264 | 265 | if not fullmatch(".*/.git/(.*/)?objects/.*", path_maybe_containing_git_dir): 266 | return path_maybe_containing_git_dir 267 | 268 | candidate_for_git_repo_home = Path(path_maybe_containing_git_dir).parent 269 | while candidate_for_git_repo_home.name != ".git": 270 | candidate_for_git_repo_home = candidate_for_git_repo_home.parent 271 | 272 | return str(candidate_for_git_repo_home) 273 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alichtman/shallow-backup/e7d3bf93b4f35069f00f6e89da05959499d004b9/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_backups.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from .testing_utility_functions import ( 5 | BACKUP_DEST_DIR, 6 | FAKE_HOME_DIR, 7 | DOTFILES, 8 | setup_dirs_and_env_vars_and_create_config, 9 | clean_up_dirs_and_env_vars, 10 | ) 11 | 12 | sys.path.insert(0, "../shallow_backup") 13 | from shallow_backup.backup import backup_dotfiles 14 | from shallow_backup.utils import safe_mkdir 15 | from shallow_backup.config import get_config, write_config 16 | 17 | TEST_TEXT_CONTENT = "THIS IS TEST CONTENT FOR THE DOTFILES" 18 | 19 | 20 | class TestBackupMethods: 21 | """Test the backup methods.""" 22 | 23 | @staticmethod 24 | def setup_method(): 25 | setup_dirs_and_env_vars_and_create_config() 26 | 27 | # Create all dotfiles and dotfolders 28 | for file in DOTFILES: 29 | if not file.endswith("/"): 30 | print(f"Creating file: {file}") 31 | os.makedirs(Path(file).parent, exist_ok=True) 32 | with open(file, "w+") as f: 33 | f.write(TEST_TEXT_CONTENT) 34 | else: 35 | directory = file 36 | print(f"Creating dir: {directory}") 37 | safe_mkdir(directory) 38 | for file_2 in ["test1", "test2"]: 39 | with open(os.path.join(directory, file_2), "w+") as f: 40 | f.write(TEST_TEXT_CONTENT) 41 | 42 | @staticmethod 43 | def teardown_method(): 44 | clean_up_dirs_and_env_vars() 45 | 46 | def test_backup_dotfiles(self): 47 | """Test backing up dotfiles and dotfolders.""" 48 | backup_dest_path = os.path.join(BACKUP_DEST_DIR, "dotfiles") 49 | backup_dotfiles( 50 | backup_dest_path, dry_run=False, home_path=FAKE_HOME_DIR, skip=True 51 | ) 52 | assert os.path.isdir(backup_dest_path) 53 | for path in DOTFILES: 54 | print( 55 | f"\nBACKUP DESTINATION DIRECTORY: ({backup_dest_path}) CONTENTS:", 56 | os.listdir(backup_dest_path), 57 | "", 58 | ) 59 | print(path + " should already be backed up.") 60 | backed_up_dot = os.path.join( 61 | backup_dest_path, path.replace(FAKE_HOME_DIR + "/", "") 62 | ) 63 | print(f"Backed up dot: {backed_up_dot}\n") 64 | assert os.path.isfile(backed_up_dot) or os.path.isdir(backed_up_dot) 65 | 66 | def test_conditions(self): 67 | """Test backing up files based on conditions""" 68 | # Set false backup condition of all files. 69 | config = get_config() 70 | print(config["dotfiles"]) 71 | for dot, _ in config["dotfiles"].items(): 72 | config["dotfiles"][dot][ 73 | "backup_condition" 74 | ] = "[[ $(uname -s) == 'Made Up OS' ]]" 75 | write_config(config) 76 | 77 | backup_dest_path = os.path.join(BACKUP_DEST_DIR, "dotfiles") 78 | backup_dotfiles( 79 | backup_dest_path, dry_run=False, home_path=FAKE_HOME_DIR, skip=True 80 | ) 81 | assert os.path.isdir(backup_dest_path) 82 | for path in DOTFILES: 83 | print( 84 | f"\nBACKUP DESTINATION DIRECTORY: ({backup_dest_path}) CONTENTS:", 85 | os.listdir(backup_dest_path), 86 | "", 87 | ) 88 | print(path + " should not be backed up.") 89 | backed_up_dot = os.path.join( 90 | backup_dest_path, path.replace(FAKE_HOME_DIR + "/", "") 91 | ) 92 | assert not (os.path.isfile(backed_up_dot) or os.path.isdir(backed_up_dot)) 93 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from .testing_utility_functions import ( 4 | setup_dirs_and_env_vars_and_create_config, 5 | clean_up_dirs_and_env_vars, 6 | FAKE_HOME_DIR, 7 | ) 8 | 9 | sys.path.insert(0, "../shallow_backup") 10 | from shallow_backup.config import ( 11 | get_config, 12 | get_config_path, 13 | add_dot_path_to_config, 14 | check_insecure_config_permissions, 15 | ) 16 | from shallow_backup.utils import strip_home 17 | 18 | 19 | class TestConfigMethods: 20 | """Test the config methods.""" 21 | 22 | @staticmethod 23 | def setup_method(): 24 | setup_dirs_and_env_vars_and_create_config() 25 | 26 | @staticmethod 27 | def teardown_method(): 28 | clean_up_dirs_and_env_vars() 29 | 30 | def test_add_path(self): 31 | config = get_config() 32 | home_path = os.path.expanduser("~") 33 | invalid_path = "some_random_nonexistent_path" 34 | path_to_add = os.path.join(home_path, invalid_path) 35 | new_config = add_dot_path_to_config(config, path_to_add) 36 | assert strip_home(invalid_path) not in new_config["dotfiles"] 37 | 38 | valid_path = "valid" 39 | path_to_add = os.path.join(FAKE_HOME_DIR, valid_path) 40 | os.mkdir(path_to_add) 41 | new_config = add_dot_path_to_config(config, path_to_add) 42 | from pprint import pprint 43 | 44 | pprint(new_config) 45 | stripped_home_path = strip_home(path_to_add) 46 | assert stripped_home_path in new_config["dotfiles"] 47 | assert isinstance(new_config["dotfiles"][stripped_home_path], dict) 48 | 49 | def test_detect_insecure_config_permissions(self): 50 | print(f"Testing config path: {get_config_path()}") 51 | os.chmod(get_config_path(), 0o777) 52 | assert check_insecure_config_permissions() == True 53 | 54 | def test_secure_config_created_by_default(self): 55 | print(f"Testing config path: {get_config_path()}") 56 | assert check_insecure_config_permissions() == False 57 | -------------------------------------------------------------------------------- /tests/test_copies.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from .testing_utility_functions import ( 5 | clean_up_dirs_and_env_vars, 6 | BACKUP_DEST_DIR, 7 | FAKE_HOME_DIR, 8 | create_dir_overwrite, 9 | setup_env_vars, 10 | create_config_for_test, 11 | ) 12 | 13 | sys.path.insert(0, "../shallow_backup") 14 | from shallow_backup.utils import copy_dir_if_valid 15 | 16 | 17 | class TestCopyMethods: 18 | """ 19 | Test the functionality of copying 20 | """ 21 | 22 | @staticmethod 23 | def setup_method(): 24 | setup_env_vars() 25 | create_config_for_test() 26 | create_dir_overwrite(FAKE_HOME_DIR) 27 | 28 | @staticmethod 29 | def teardown_method(): 30 | clean_up_dirs_and_env_vars() 31 | 32 | def test_copy_dir(self): 33 | """ 34 | Test that copying a directory works as expected 35 | """ 36 | # TODO: Test that all subfiles and folders are copied. 37 | test_file_name = "test-file.txt" 38 | test_dir_name = "subdir-to-copy" 39 | os.mkdir(os.path.join(FAKE_HOME_DIR, test_dir_name)) 40 | with open(os.path.join(FAKE_HOME_DIR, test_file_name), "w+") as file: 41 | file.write("TRASH") 42 | 43 | copy_dir_if_valid(FAKE_HOME_DIR, BACKUP_DEST_DIR) 44 | assert os.path.isfile(os.path.join(BACKUP_DEST_DIR, test_file_name)) 45 | assert os.path.isdir(os.path.join(BACKUP_DEST_DIR, test_dir_name)) 46 | 47 | @pytest.mark.parametrize("invalid", {".Trash", ".npm", ".cache", ".rvm"}) 48 | def test_copy_dir_invalid(self, invalid): 49 | """ 50 | Test that attempting to copy an invalid directory fails 51 | """ 52 | copy_dir_if_valid(invalid, FAKE_HOME_DIR) 53 | assert not os.path.isdir(os.path.join(BACKUP_DEST_DIR, invalid)) 54 | -------------------------------------------------------------------------------- /tests/test_delete_backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from .testing_utility_functions import ( 4 | FAKE_HOME_DIR, 5 | clean_up_dirs_and_env_vars, 6 | setup_dirs_and_env_vars_and_create_config, 7 | ) 8 | 9 | sys.path.insert(0, "../shallow_backup") 10 | from shallow_backup.utils import destroy_backup_dir 11 | 12 | TEST_BACKUP_TEXT_FILE = os.path.join(FAKE_HOME_DIR, "test-file.txt") 13 | 14 | 15 | class TestDeleteMethods: 16 | """ 17 | Test the functionality of deleting 18 | """ 19 | 20 | @staticmethod 21 | def setup_method(): 22 | setup_dirs_and_env_vars_and_create_config() 23 | with open(TEST_BACKUP_TEXT_FILE, "w+") as file: 24 | file.write("RANDOM TEXT") 25 | 26 | @staticmethod 27 | def teardown_method(): 28 | clean_up_dirs_and_env_vars() 29 | 30 | def test_clean_an_existing_backup_directory(self): 31 | """ 32 | Test that deleting the backup directory works as expected 33 | """ 34 | assert os.path.isdir(FAKE_HOME_DIR) 35 | assert os.path.isfile(TEST_BACKUP_TEXT_FILE) 36 | destroy_backup_dir(FAKE_HOME_DIR) 37 | assert not os.path.isdir(FAKE_HOME_DIR) 38 | assert not os.path.isfile(TEST_BACKUP_TEXT_FILE) 39 | 40 | def test_can_handle_cleaning_non_existing_backup_directory(self): 41 | """ 42 | Test that we exit gracefully when cleaning an non existing backup directory 43 | """ 44 | nonexist_backup_dir = os.path.join(FAKE_HOME_DIR, "NON-EXISTENT") 45 | assert not os.path.isdir(nonexist_backup_dir) 46 | destroy_backup_dir(nonexist_backup_dir) 47 | assert not os.path.isdir(nonexist_backup_dir) 48 | -------------------------------------------------------------------------------- /tests/test_git_folder_moving.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from .testing_utility_functions import ( 4 | BACKUP_DEST_DIR, 5 | FAKE_HOME_DIR, 6 | clean_up_dirs_and_env_vars, 7 | setup_dirs_and_env_vars_and_create_config, 8 | ) 9 | 10 | sys.path.insert(0, "../shallow_backup") 11 | from shallow_backup.git_wrapper import move_git_repo, safe_git_init, create_gitignore 12 | 13 | 14 | class TestGitFolderCopying: 15 | """ 16 | Test the functionality of .git copying 17 | """ 18 | 19 | @staticmethod 20 | def setup_method(): 21 | setup_dirs_and_env_vars_and_create_config() 22 | 23 | @staticmethod 24 | def teardown_method(): 25 | clean_up_dirs_and_env_vars() 26 | 27 | def test_copy_git_folder(self): 28 | """ 29 | Test copying the .git folder and .gitignore from an old directory to a new one 30 | """ 31 | safe_git_init(FAKE_HOME_DIR) 32 | create_gitignore(FAKE_HOME_DIR, "root-gitignore") 33 | move_git_repo(FAKE_HOME_DIR, BACKUP_DEST_DIR) 34 | assert os.path.isdir(os.path.join(BACKUP_DEST_DIR, ".git/")) 35 | assert os.path.isfile(os.path.join(BACKUP_DEST_DIR, ".gitignore")) 36 | assert not os.path.isdir(os.path.join(FAKE_HOME_DIR, ".git/")) 37 | assert not os.path.isfile(os.path.join(FAKE_HOME_DIR, ".gitignore")) 38 | -------------------------------------------------------------------------------- /tests/test_reinstall_dotfiles.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from .testing_utility_functions import ( 4 | FAKE_HOME_DIR, 5 | setup_dirs_and_env_vars_and_create_config, 6 | clean_up_dirs_and_env_vars, 7 | ) 8 | 9 | sys.path.insert(0, "../shallow_backup") 10 | from shallow_backup.reinstall import reinstall_dots_sb 11 | 12 | TEST_TEXT_CONTENT = "THIS IS TEST CONTENT FOR THE DOTFILES" 13 | DOTFILES_PATH = os.path.join(FAKE_HOME_DIR, "dotfiles/") 14 | 15 | 16 | class TestReinstallDotfiles: 17 | """ 18 | Test the functionality of reinstalling dotfiles 19 | """ 20 | 21 | @staticmethod 22 | def setup_method(): 23 | def create_nested_dir(parent, name): 24 | new_dir = os.path.join(parent, name) 25 | print(f"Creating {new_dir}") 26 | if not os.path.isdir(new_dir): 27 | os.makedirs(new_dir) 28 | return new_dir 29 | 30 | def create_file(parent, name): 31 | file = os.path.join(parent, name) 32 | print(f"Creating {file}") 33 | with open(file, "w+") as f: 34 | f.write(TEST_TEXT_CONTENT) 35 | 36 | def create_git_dir(parent): 37 | git_dir = create_nested_dir(parent, ".git/") 38 | git_objects = create_nested_dir(git_dir, "objects/") 39 | create_file(git_dir, "config") 40 | create_file(git_objects, "obj1") 41 | return git_dir 42 | 43 | setup_dirs_and_env_vars_and_create_config() 44 | 45 | # Dotfiles / dirs to NOT reinstall 46 | create_git_dir(DOTFILES_PATH) # Should NOT reinstall DOTFILES_PATH/.git 47 | img_dir_should_not_reinstall = create_nested_dir(DOTFILES_PATH, "img") 48 | create_file(img_dir_should_not_reinstall, "test.png") 49 | create_file(DOTFILES_PATH, "README.md") 50 | create_file(DOTFILES_PATH, ".gitignore") 51 | 52 | # Dotfiles / dirs to reinstall 53 | testfolder = create_nested_dir(DOTFILES_PATH, ".config/tmux/") 54 | testfolder2 = create_nested_dir(testfolder, "testfolder2/") 55 | create_file(testfolder2, "test.sh") 56 | create_git_dir(testfolder2) 57 | git_config = create_nested_dir(DOTFILES_PATH, ".config/git") 58 | create_file(git_config, "test") 59 | create_file(testfolder2, ".gitignore") 60 | create_file(DOTFILES_PATH, ".zshenv") 61 | 62 | @staticmethod 63 | def teardown_method(): 64 | clean_up_dirs_and_env_vars() 65 | 66 | def test_reinstall_dotfiles(self): 67 | """ 68 | Test reinstalling dotfiles to fake home dir 69 | """ 70 | reinstall_dots_sb( 71 | dots_path=DOTFILES_PATH, home_path=FAKE_HOME_DIR, dry_run=False 72 | ) 73 | assert os.path.isfile(os.path.join(FAKE_HOME_DIR, ".zshenv")) 74 | testfolder2 = os.path.join( 75 | os.path.join(FAKE_HOME_DIR, ".config/tmux/"), "testfolder2" 76 | ) 77 | assert os.path.isdir(testfolder2) 78 | assert os.path.isfile(os.path.join(testfolder2, "test.sh")) 79 | assert os.path.isdir(os.path.join(FAKE_HOME_DIR, ".config/git/")) 80 | 81 | # Do reinstall other git files 82 | assert os.path.isdir(os.path.join(testfolder2, ".git")) 83 | assert os.path.isfile(os.path.join(testfolder2, ".gitignore")) 84 | 85 | # Don't reinstall root-level git files 86 | assert not os.path.isdir(os.path.join(FAKE_HOME_DIR, ".git")) 87 | assert not os.path.isfile(os.path.join(FAKE_HOME_DIR, ".gitignore")) 88 | 89 | # Don't reinstall img or README.md 90 | assert not os.path.isdir(os.path.join(FAKE_HOME_DIR, "img")) 91 | assert not os.path.isfile(os.path.join(FAKE_HOME_DIR, "README.md")) 92 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .testing_utility_functions import setup_env_vars, unset_env_vars 3 | 4 | sys.path.insert(0, "../shallow_backup") 5 | from shallow_backup.utils import run_cmd_return_bool 6 | 7 | 8 | class TestUtilMethods: 9 | """ 10 | Test the functionality of utils 11 | """ 12 | 13 | @staticmethod 14 | def setup_method(): 15 | setup_env_vars() 16 | 17 | @staticmethod 18 | def teardown_method(): 19 | unset_env_vars() 20 | 21 | def test_run_cmd_return_bool(self): 22 | """Test that evaluating bash commands to get booleans works as expected""" 23 | 24 | # Basic bash conditionals with command substitution 25 | should_fail = "[[ -z $(uname -s) ]]" 26 | assert run_cmd_return_bool(should_fail) is False 27 | 28 | should_pass = "[[ -n $(uname -s) ]]" 29 | assert run_cmd_return_bool(should_pass) is True 30 | 31 | # Using env vars 32 | should_pass = '[[ -n "$SHALLOW_BACKUP_TEST_BACKUP_DIR" ]]' 33 | assert run_cmd_return_bool(should_pass) is True 34 | 35 | should_pass = "[[ -n $SHALLOW_BACKUP_TEST_BACKUP_DIR ]]" 36 | assert run_cmd_return_bool(should_pass) is True 37 | -------------------------------------------------------------------------------- /tests/testing_utility_functions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | sys.path.insert(0, "../shallow_backup") 6 | from shallow_backup.config import safe_create_config 7 | 8 | 9 | def setup_env_vars(): 10 | os.environ["SHALLOW_BACKUP_TEST_BACKUP_DIR"] = ( 11 | os.path.abspath(BASE_TEST_DIR) + "/backup" 12 | ) 13 | os.environ["SHALLOW_BACKUP_TEST_HOME_DIR"] = ( 14 | os.path.abspath(BASE_TEST_DIR) + "/home" 15 | ) 16 | # This env var is referenced in shallow_backup/config.py 17 | os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"] = ( 18 | os.path.abspath(BASE_TEST_DIR) + "/shallow-backup.conf" 19 | ) 20 | 21 | 22 | def unset_env_vars(): 23 | del os.environ["SHALLOW_BACKUP_TEST_BACKUP_DIR"] 24 | del os.environ["SHALLOW_BACKUP_TEST_HOME_DIR"] 25 | del os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"] 26 | 27 | 28 | def create_config_for_test(): 29 | config_file = os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"] 30 | if os.path.isfile(config_file): 31 | os.remove(config_file) 32 | safe_create_config() 33 | 34 | 35 | def create_dir_overwrite(directory): 36 | if os.path.isdir(directory): 37 | shutil.rmtree(directory) 38 | os.makedirs(directory) 39 | 40 | 41 | def setup_dirs_and_env_vars_and_create_config(): 42 | setup_env_vars() 43 | create_config_for_test() 44 | for directory in DIRS: 45 | create_dir_overwrite(directory) 46 | 47 | 48 | def clean_up_dirs_and_env_vars(): 49 | shutil.rmtree(BASE_TEST_DIR) 50 | unset_env_vars() 51 | 52 | 53 | # TODO: Update to tempfile and tempdir because testing in the home directory is so stupid. 54 | 55 | # These globals must remain at the bottom of this file for some reason 56 | # This global is required to be set for the setup_env_vars call to work properly. 57 | BASE_TEST_DIR = os.path.expanduser("~") + "/SHALLOW-BACKUP-TEST-DIRECTORY" 58 | setup_env_vars() 59 | BACKUP_DEST_DIR = os.environ.get("SHALLOW_BACKUP_TEST_BACKUP_DIR") 60 | FAKE_HOME_DIR = os.environ.get("SHALLOW_BACKUP_TEST_HOME_DIR") 61 | DIRS = [BACKUP_DEST_DIR, FAKE_HOME_DIR] 62 | 63 | DOTFILES = [ 64 | os.path.join(FAKE_HOME_DIR, ".ssh/"), 65 | os.path.join(FAKE_HOME_DIR, ".config/git/"), 66 | os.path.join(FAKE_HOME_DIR, ".zshenv"), 67 | os.path.join(FAKE_HOME_DIR, ".pypirc"), 68 | os.path.join(FAKE_HOME_DIR, ".config/nvim/init.vim"), 69 | os.path.join(FAKE_HOME_DIR, ".config/zsh/.zshrc"), 70 | ] 71 | --------------------------------------------------------------------------------