[
  {
    "path": ".github/workflows/build-pull-request.yml",
    "content": "name: build-pull-request\n\non: [ pull_request ]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n      # this is consistently timing out on github, not sure why\n      # - uses: gradle/actions/wrapper-validation@v3\n      - uses: actions/setup-java@v4\n        with:\n          distribution: temurin\n          java-version: 25\n\n      - name: Set up Gradle\n        uses: gradle/actions/setup-gradle@v4\n        with:\n          # Releases are not published with github actions, so I'm less concerned about cache poisoning\n          cache-read-only: false\n\n      # Aggressively cache Gradle home and build dirs\n      - name: Cache Gradle dependencies\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.gradle/caches\n            ~/.gradle/wrapper\n          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}\n          restore-keys: |\n            ${{ runner.os }}-gradle-\n\n      - name: Cache build outputs\n        uses: actions/cache@v4\n        with:\n          path: |\n            **/build\n          key: ${{ runner.os }}-build-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-build-\n\n      - name: Execute Gradle build\n        run: ./gradlew build\n"
  },
  {
    "path": ".gitignore",
    "content": "# gradle\n\n\n*~\n\\#*\n\n\n\n.gradle/\nbuild/\nout/\nclasses/\n\n# eclipse\n\n*.launch\n\n# idea\n\n.idea/\n*.iml\n*.ipr\n*.iws\n\n# vscode\n.settings/\n.vscode/\n.classpath\n.project\n*/bin\n\n# macos\n\n*.DS_Store\n\n# fabric\n\nrun/\nlogs/\n\n"
  },
  {
    "path": "Justfile",
    "content": "clean:\n    rm -rf build\n\ncompile:\n    ./gradlew compileJava\n\njar:\n    ./gradlew jar\n\nrelease-jars:\n    ./gradlew buildReleaseJars\n\ncompile-common:\n    ./gradlew :common:compileJava\n\ntest:\n    ./gradlew test\n\nrelease:\n    ./gradlew release\n\nide:\n    ./gradlew cleanIdea idea\n\npr:\n    gh pr view --web 2>/dev/null || gh pr create --web\n\nprs:\n    {{ if os() == \"macos\" { \"open\" } else { \"firefox\" } }} https://github.com/pcal43/copper-hopper/pulls\n\ndeps:\n    ./gradlew -q dependencies --configuration runtimeClasspath\n\nclearCaches:\n    ./gradlew --stop\n    rm -rf \"$HOME/.gradle/caches\" \"$HOME/.gradle/wrapper/dists\" \"$HOME/.gradle/daemon\" \"$HOME/.gradle/native\"\n\nrun-fabric:\n    ./gradlew :fabric:runClient\n\nrun-fabric-server:\n    ./gradlew :fabric:runServer\n\nrun-neoforge:\n    ./gradlew :neoforge:runClient\n\nrun-neoforge-server:\n    ./gradlew :neoforge:runServer\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n\n"
  },
  {
    "path": "README.md",
    "content": "# FastBack\n*Fast, incremental Minecraft world backups powered by Git*\n\nFastback is a Minecraft mod that backs up your world in incremental snapshots.  When it does a backup,\nit only saves the parts of your world that changed.\n\nThis means backups are fast.  It also means you can keep snapshots of your world without using up a lot\nof disk space.\n\n![](https://pcal43.github.io/fastback/savescreen_animation.gif)\n\n## Features\n\n* Incrementally backup just the changed files\n* Faster, smaller backups than zipping\n* Back up locally\n* Back up remotely to any git server\n* Back up remotely to any network volume (no git server required)\n* Schedule backups to run automatically\n* Easily restore backup snapshots\n* Snapshot pruning, retention policies\n* Include mod jars and config files in backup snapshots\n* Broadcast server-wide notifications during backups\n* LuckPerms support\n* Works on clients and dedicated servers\n* Works on Linux, Mac and Windows\n* ..all with easy-to-use minecraft commands\n\n## Acknowledgements\n\n* Russian localization provided by [Felix14-v2](https://github.com/Felix14-v2)\n* Chinese localization provided by [buiawpkgew1](https://github.com/buiawpkgew1)\n* Spanish localization provided by [rmortes](https://github.com/rmortes)\n* Fastback includes and was made possible by the work of committers on these projects:\n    * [JGit](https://www.eclipse.org/jgit/) from The Eclipse Software Foundation\n    * [sshd](https://mina.apache.org/sshd-project/) from The Apache Software Foundation\n    * [JavaEWAH](https://github.com/lemire/javaewah) from Daniel Lemire, et al.\n    * [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api) from lucko\n    * [server-translations-api](https://github.com/NucleoidMC/Server-Translations) from the Fabric Community\n\n## Legal\n\nFastBack is distributed under [GNU Public License version 2](https://github.com/pcal43/fastback/blob/main/LICENSE).\n\nYou can put it in a modpack but please include attribution with a link to this page.\n\n## Questions?\n\nPlease start with the [documentation](https://pcal43.github.io/fastback).  If you have more\nquestions, please join \"pcal's minecraft mods\" on Discord:\n\n[https://discord.pcal.net](https://discord.pcal.net)\n\nNote that Curseforge comments have been disabled and I will **not** reply to private messages.\n\n"
  },
  {
    "path": "build.gradle",
    "content": "plugins {\n\tid 'net.fabricmc.fabric-loom' version \"${fabric_loom_version}\" apply false\n\tid 'net.neoforged.moddev' version \"${neoforge_moddev_version}\" apply false\n\tid 'com.modrinth.minotaur' version \"${minotaur_plugin_version}\" apply false\n\tid 'net.darkhax.curseforgegradle' version \"${curseforgegradle_plugin_version}\" apply false\n}\n\next {\n\tarchivesBaseNameFabric = \"${archives_base_name}-fabric\"\n\tarchivesBaseNameNeoForge = \"${archives_base_name}-neoforge\"\n}\n\nsubprojects {\n\ttasks.withType(JavaCompile).configureEach {\n\t\tit.options.release = project.java_version as Integer\n\t\toptions.encoding = \"UTF-8\"\n\t}\n}\n\n// ============================================================================\n// Release Tasks\n// ============================================================================\n\n/**\n * Validates that the working directory is clean and on the main branch\n */\ntasks.register('validateRelease') {\n\tgroup = 'release'\n\tdescription = 'Validates that the environment is ready for a release'\n\n\tdoFirst {\n\t\tif (!System.getenv(\"CURSEFORGE_TOKEN\")) {\n\t\t\tthrow new GradleException(\"CURSEFORGE_TOKEN environment variable not set\")\n\t\t}\n\t\tif (!System.getenv(\"MODRINTH_TOKEN\")) {\n\t\t\tthrow new GradleException(\"MODRINTH_TOKEN environment variable not set\")\n\t\t}\n\t}\n\n\tdoLast {\n\t\t// Check git status\n\t\tdef gitStatus = 'git status --porcelain'.execute().text.trim()\n\t\tif (!gitStatus.isEmpty()) {\n\t\t\tthrow new GradleException(\"Working directory not clean, cannot release:\\n${gitStatus}\")\n\t\t}\n\n\t\t// Check branch\n\t\tdef currentBranch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()\n\t\tif (currentBranch != 'main') {\n\t\t\tthrow new GradleException(\"Releases must be performed on main. Currently on '${currentBranch}'\")\n\t\t}\n\n\t\tprintln \"✓ Working directory is clean\"\n\t\tprintln \"✓ On main branch\"\n\t}\n}\n\n/**\n * Removes the -prerelease suffix from mod_version in gradle.properties\n */\ntasks.register('prepareReleaseVersion') {\n\tgroup = 'release'\n\tdescription = 'Removes -prerelease suffix from mod_version'\n\n\tdoLast {\n\t\tdef propsFile = file('gradle.properties')\n\t\tdef content = propsFile.text\n\n\t\t// Extract current version using regex\n\t\tdef matcher = content =~ /(?m)^mod_version\\s*=\\s*(.+)$/\n\t\tif (!matcher.find()) {\n\t\t\tthrow new GradleException(\"Could not find mod_version in gradle.properties\")\n\t\t}\n\n\t\tdef currentVersion = matcher.group(1).trim()\n\t\tprintln \"Current version: ${currentVersion}\"\n\n\t\tif (!currentVersion.contains('-prerelease')) {\n\t\t\tthrow new GradleException(\"Current version is not a prerelease: ${currentVersion}\")\n\t\t}\n\n\t\tdef releaseVersion = currentVersion.replace('-prerelease', '')\n\t\tprintln \"Release version: ${releaseVersion}\"\n\n\t\t// Replace the line in the file, preserving everything else\n\t\tdef updatedContent = content.replaceAll(\n\t\t\t/(?m)^mod_version\\s*=\\s*.+$/,\n\t\t\t\"mod_version = ${releaseVersion}\"\n\t\t)\n\n\t\tpropsFile.text = updatedContent\n\n\t\tprintln \"✓ Updated gradle.properties to version ${releaseVersion}\"\n\n\t\t// Store for later tasks\n\t\tproject.ext.releaseVersion = releaseVersion\n\t}\n}\n\n/**\n * Commits the version change to git\n */\ntasks.register('commitReleaseVersion') {\n\tgroup = 'release'\n\tdescription = 'Commits the release version to git'\n\n\tdoLast {\n\t\tdef releaseVersion = project.ext.releaseVersion\n\n\t\t// Stage and commit\n\t\tdef addResult = [\"git\", \"add\", \"gradle.properties\"].execute()\n\t\tdef addOutput = new StringBuilder()\n\t\tdef addError = new StringBuilder()\n\t\taddResult.consumeProcessOutput(addOutput, addError)\n\t\taddResult.waitFor()\n\t\tif (addResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to stage gradle.properties: ${addError}\")\n\t\t}\n\n\t\tdef commitResult = [\"git\", \"commit\", \"-m\", \"*** Release ${releaseVersion} ***\"].execute()\n\t\tdef commitOutput = new StringBuilder()\n\t\tdef commitError = new StringBuilder()\n\t\tcommitResult.consumeProcessOutput(commitOutput, commitError)\n\t\tcommitResult.waitFor()\n\t\tif (commitResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to commit release version:\\n${commitOutput}\\n${commitError}\")\n\t\t}\n\n\t\tprintln \"✓ Committed release version ${releaseVersion}\"\n\t}\n}\n\n/**\n * Builds the release jars for both Fabric and NeoForge\n * Runs as separate Gradle invocation to pick up updated gradle.properties\n */\ntasks.register('buildReleaseJars') {\n\tgroup = 'release'\n\tdescription = 'Builds Fabric and NeoForge release jars'\n\n\tdoLast {\n\t\tdef releaseVersion = project.ext.releaseVersion\n\n\t\tprintln \"Building release jars with version ${releaseVersion}...\"\n\n\t\t// Run Gradle as subprocess to pick up updated gradle.properties\n\t\t// Clean first to remove any cached artifacts with old version\n\t\tdef gradleCmd = [\n\t\t\t'./gradlew',\n\t\t\t'clean',\n\t\t\t':fabric:build',\n\t\t\t':neoforge:build',\n\t\t\t'--console=plain'\n\t\t]\n\n\t\tdef buildProcess = gradleCmd.execute()\n\t\tbuildProcess.waitForProcessOutput(System.out, System.err)\n\n\t\tif (buildProcess.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to build release jars\")\n\t\t}\n\n\t\tprintln \"✓ Built release jars:\"\n\t\tprintln \"  - fabric/build/libs/${archivesBaseNameFabric}-${releaseVersion}.jar\"\n\t\tprintln \"  - neoforge/build/libs/${archivesBaseNameNeoForge}-${releaseVersion}.jar\"\n\t}\n}\n\n/**\n * Pushes changes and creates a GitHub release\n */\ntasks.register('publishGitHub') {\n\tgroup = 'release'\n\tdescription = 'Pushes to git and creates GitHub release'\n\n\tdoLast {\n\t\tdef releaseVersion = project.ext.releaseVersion\n\n\t\t// Push commits\n\t\tprintln \"Pushing to origin...\"\n\t\tdef pushResult = [\"git\", \"push\"].execute()\n\t\tpushResult.waitForProcessOutput(System.out, System.err)\n\t\tif (pushResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to push to git\")\n\t\t}\n\n\t\t// Create GitHub release\n\t\tprintln \"Creating GitHub release ${releaseVersion}...\"\n\t\tdef currentBranch = 'git branch --show-current'.execute().text.trim()\n\n\t\tdef fabricJar = file(\"fabric/build/libs/${archivesBaseNameFabric}-${releaseVersion}.jar\")\n\t\tdef neoforgeJar = file(\"neoforge/build/libs/${archivesBaseNameNeoForge}-${releaseVersion}.jar\")\n\n\t\tif (!fabricJar.exists()) throw new GradleException(\"Fabric release jar not found at ${fabricJar}\")\n\t\tif (!neoforgeJar.exists()) throw new GradleException(\"NeoForge release jar not found at ${neoforgeJar}\")\n\n\t\tdef ghCmd = [\n\t\t\t'gh', 'release', 'create',\n\t\t\t'--target', currentBranch,\n\t\t\t'--generate-notes',\n\t\t\t'--title', releaseVersion,\n\t\t\t'--notes', \"release ${releaseVersion}\",\n\t\t\treleaseVersion,\n\t\t\tfabricJar.absolutePath,\n\t\t\tneoforgeJar.absolutePath\n\t\t]\n\n\t\tdef ghResult = ghCmd.execute()\n\t\tghResult.waitForProcessOutput(System.out, System.err)\n\t\tif (ghResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to create GitHub release\")\n\t\t}\n\n\t\tprintln \"✓ Created GitHub release ${releaseVersion}\"\n\t}\n}\n\n/**\n * Publishes to CurseForge for both Fabric and NeoForge\n * Runs as separate Gradle invocation to use updated gradle.properties\n */\ntasks.register('publishCurseForge') {\n\tgroup = 'release'\n\tdescription = 'Publishes to CurseForge'\n\n\tdoLast {\n\t\tprintln \"Publishing to CurseForge...\"\n\n\t\tdef releaseVersion = project.ext.releaseVersion\n\t\tdef gradleCmd = [\n\t\t\t'./gradlew',\n\t\t\t':fabric:publishCurseforge',\n\t\t\t':neoforge:publishCurseforge',\n\t\t\t'--console=plain'\n\t\t]\n\n\t\tdef publishProcess = gradleCmd.execute()\n\t\tpublishProcess.waitForProcessOutput(System.out, System.err)\n\n\t\tif (publishProcess.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to publish to CurseForge\")\n\t\t}\n\n\t\tprintln \"✓ Published to CurseForge\"\n\t}\n}\n\n/**\n * Publishes to Modrinth for both Fabric and NeoForge\n * Runs as separate Gradle invocation to use updated gradle.properties\n */\ntasks.register('publishModrinth') {\n\tgroup = 'release'\n\tdescription = 'Publishes to Modrinth'\n\n\tdoLast {\n\t\tprintln \"Publishing to Modrinth...\"\n\n\t\tdef releaseVersion = project.ext.releaseVersion\n\t\tdef gradleCmd = [\n\t\t\t'./gradlew',\n\t\t\t':fabric:modrinth',\n\t\t\t':neoforge:modrinth',\n\t\t\t'--console=plain'\n\t\t]\n\n\t\tdef publishProcess = gradleCmd.execute()\n\t\tpublishProcess.waitForProcessOutput(System.out, System.err)\n\n\t\tif (publishProcess.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to publish to Modrinth\")\n\t\t}\n\n\t\tprintln \"✓ Published to Modrinth\"\n\t}\n}\n\n/**\n * Increments version and adds -prerelease suffix for next development cycle\n */\ntasks.register('bumpVersion') {\n\tgroup = 'release'\n\tdescription = 'Increments version and adds -prerelease suffix'\n\n\tdoLast {\n\t\tdef propsFile = file('gradle.properties')\n\t\tdef content = propsFile.text\n\n\t\t// Extract current version using regex\n\t\tdef matcher = content =~ /(?m)^mod_version\\s*=\\s*(.+)$/\n\t\tif (!matcher.find()) {\n\t\t\tthrow new GradleException(\"Could not find mod_version in gradle.properties\")\n\t\t}\n\n\t\tdef releaseVersion = matcher.group(1).trim()\n\t\tprintln \"Previous release version: ${releaseVersion}\"\n\n\t\t// Parse the version: \"0.23.0+1.21.10\" -> major.minor.patch + buildMetadata\n\t\tdef versionParts = releaseVersion.split('\\\\+')\n\t\tdef semver = versionParts[0].split('\\\\.')\n\t\tdef buildMetadata = versionParts.length > 1 ? versionParts[1] : ''\n\n\t\t// Increment patch version\n\t\tdef major = semver[0]\n\t\tdef minor = semver[1]\n\t\tdef patch = (semver[2] as Integer) + 1\n\n\t\tdef nextVersion = \"${major}.${minor}.${patch}+${buildMetadata}-prerelease\"\n\t\tprintln \"Next version: ${nextVersion}\"\n\n\t\t// Replace the line in the file, preserving everything else\n\t\tdef updatedContent = content.replaceAll(\n\t\t\t/(?m)^mod_version\\s*=\\s*.+$/,\n\t\t\t\"mod_version = ${nextVersion}\"\n\t\t)\n\n\t\tpropsFile.text = updatedContent\n\n\t\tprintln \"✓ Updated gradle.properties to version ${nextVersion}\"\n\n\t\t// Store for later tasks\n\t\tproject.ext.nextVersion = nextVersion\n\t}\n}\n\n/**\n * Commits and pushes the version bump\n */\ntasks.register('commitVersionBump') {\n\tgroup = 'release'\n\tdescription = 'Commits and pushes the version bump'\n\n\tdoLast {\n\t\tdef nextVersion = project.ext.nextVersion\n\n\t\t// Commit and push\n\t\tdef addResult = [\"git\", \"add\", \"gradle.properties\"].execute()\n\t\tdef addOutput = new StringBuilder()\n\t\tdef addError = new StringBuilder()\n\t\taddResult.consumeProcessOutput(addOutput, addError)\n\t\taddResult.waitFor()\n\t\tif (addResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to stage gradle.properties: ${addError}\")\n\t\t}\n\n\t\tdef commitResult = [\"git\", \"commit\", \"-m\", \"Prepare for next version ${nextVersion}\"].execute()\n\t\tdef commitOutput = new StringBuilder()\n\t\tdef commitError = new StringBuilder()\n\t\tcommitResult.consumeProcessOutput(commitOutput, commitError)\n\t\tcommitResult.waitFor()\n\t\tif (commitResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to commit version bump:\\n${commitOutput}\\n${commitError}\")\n\t\t}\n\n\t\tdef pushResult = [\"git\", \"push\"].execute()\n\t\tpushResult.waitForProcessOutput(System.out, System.err)\n\t\tif (pushResult.exitValue() != 0) {\n\t\t\tthrow new GradleException(\"Failed to push version bump\")\n\t\t}\n\n\t\tprintln \"✓ Committed and pushed version bump to ${nextVersion}\"\n\t}\n}\n\n/**\n * Complete release workflow\n *\n * Executes tasks in this order:\n *   1. validateRelease       - Check git status\n *   2. prepareReleaseVersion - Remove -prerelease suffix\n *   3. commitReleaseVersion  - Commit version change\n *   4. buildReleaseJars      - Build both loaders\n *   5. publishGitHub         - Push and create GitHub release\n *   6. publishCurseForge     - Publish to CurseForge\n *   7. publishModrinth       - Publish to Modrinth\n *   8. bumpVersion           - Increment version, add -prerelease\n *   9. commitVersionBump     - Commit and push next version\n */\ntasks.register('release') {\n\tgroup = 'release'\n\tdescription = 'Complete release: GitHub, CurseForge, Modrinth, and version bump'\n\n\t// All tasks that need to run\n\tdependsOn validateRelease,\n\t          prepareReleaseVersion,\n\t          commitReleaseVersion,\n\t          buildReleaseJars,\n\t          publishGitHub,\n\t          publishCurseForge,\n\t          publishModrinth,\n\t          bumpVersion,\n\t          commitVersionBump\n\n\t// Execution order\n\tprepareReleaseVersion.mustRunAfter validateRelease\n\tcommitReleaseVersion.mustRunAfter prepareReleaseVersion\n\tbuildReleaseJars.mustRunAfter commitReleaseVersion\n\tpublishGitHub.mustRunAfter buildReleaseJars\n\tpublishCurseForge.mustRunAfter publishGitHub\n\tpublishModrinth.mustRunAfter publishCurseForge\n\tbumpVersion.mustRunAfter publishModrinth\n\tcommitVersionBump.mustRunAfter bumpVersion\n\n\tdoFirst {\n\t\tprintln \"\"\n\t\tprintln \"╔════════════════════════════════════════════════════════════╗\"\n\t\tprintln \"║                   Starting Release Process                 ║\"\n\t\tprintln \"╚════════════════════════════════════════════════════════════╝\"\n\t\tprintln \"\"\n\t}\n\n\tdoLast {\n\t\tprintln \"\"\n\t\tprintln \"╔════════════════════════════════════════════════════════════╗\"\n\t\tprintln \"║                   Release Complete! 🎉                     ║\"\n\t\tprintln \"╚════════════════════════════════════════════════════════════╝\"\n\t\tprintln \"\"\n\t}\n}\n"
  },
  {
    "path": "common/build.gradle",
    "content": "plugins {\n\tid 'net.fabricmc.fabric-loom'\n}\n\nrepositories {\n    maven { url = 'https://maven.fabricmc.net/' }\n    maven { url = 'https://repo.spongepowered.org/maven/' }\n    maven { url = 'https://maven.nucleoid.xyz/' }\n    mavenCentral()\n}\n\ndependencies {\n\tminecraft \"com.mojang:minecraft:${minecraft_version}\"\n\n\tcompileOnly \"org.spongepowered:mixin:${spongepowered_version}\"\n\tannotationProcessor \"org.spongepowered:mixin:${spongepowered_version}:processor\"\n\n\ttestImplementation \"org.junit.jupiter:junit-jupiter-api:${junit_version}\"\n\ttestRuntimeOnly \"org.junit.jupiter:junit-jupiter-engine:${junit_version}\"\n\ttestRuntimeOnly \"org.junit.platform:junit-platform-launcher:${junit_platform_version}\"\n\n\t// jgit\n\tapi(\"org.eclipse.jgit:org.eclipse.jgit:${jgit_version}\") { transitive = false }\n\n\t// jgit needs this\n\truntimeOnly(\"com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}\") { transitive = false }\n\n\t// So jgit can do modern ssh:\n\t// sshd-common is NOT listed here - the fabric module provides a patched version that strips the\n\t// FileSystemProvider service entry to prevent a ClassCastException during Fabric's jar scanning.\n\truntimeOnly(\"org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}\") { transitive = false }\n\truntimeOnly(\"org.apache.sshd:sshd-core:${apache_sshd_version}\") { transitive = false }\n\n\t// this enables ed25519 support in apache_sshd\n\t// https://github.com/apache/mina-sshd/blob/dfa109b7b535d64e8ee395ddd0419e7696fb24ee/docs/dependencies.md\n\truntimeOnly(\"net.i2p.crypto:eddsa:${project.eddsa_version}\") { transitive = false }\n\n\truntimeOnly(\"me.lucko:fabric-permissions-api:${fabric_permissions_version}\") { transitive = false }\n\truntimeOnly(\"xyz.nucleoid:server-translations-api:${server_translations_version}\")  { transitive = false }\n}\n\nloom {\n\truns {}\n}\n\ntest {\n\tuseJUnitPlatform()\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/Command.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\n\npublic interface Command {\n\n    void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf);\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/Commands.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.repo.RepoFactory;\nimport net.pcal.fastback.common.utils.Executor.ExecutionLock;\n\nimport java.nio.file.Path;\nimport java.util.function.Predicate;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk;\nimport static net.pcal.fastback.common.utils.Executor.executor;\n\npublic class Commands {\n\n    static final int FAILURE = 0;\n    static final int SUCCESS = 1;\n\n\n    public static LiteralArgumentBuilder<CommandSourceStack> createBackupCommand(final PermissionsFactory<CommandSourceStack> pf) {\n\n        final LiteralArgumentBuilder<CommandSourceStack> root = LiteralArgumentBuilder.<CommandSourceStack>literal(\"backup\").\n                requires(pf.require(\"fastback.command\")).\n                executes(HelpCommand::generalHelp);\n\n        InitCommand.INSTANCE.register(root, pf);\n        LocalCommand.INSTANCE.register(root, pf);\n        FullCommand.INSTANCE.register(root, pf);\n        InfoCommand.INSTANCE.register(root, pf);\n\n        RestoreCommand.INSTANCE.register(root, pf);\n        CreateFileRemoteCommand.INSTANCE.register(root, pf);\n\n        PruneCommand.INSTANCE.register(root, pf);\n        DeleteCommand.INSTANCE.register(root, pf);\n        GcCommand.INSTANCE.register(root, pf);\n        ListCommand.INSTANCE.register(root, pf);\n        PushCommand.INSTANCE.register(root, pf);\n\n        RemoteListCommand.INSTANCE.register(root, pf);\n        RemoteDeleteCommand.INSTANCE.register(root, pf);\n        RemotePruneCommand.INSTANCE.register(root, pf);\n        RemoteRestoreCommand.INSTANCE.register(root, pf);\n\n        SetCommand.INSTANCE.register(root, pf);\n\n        HelpCommand.INSTANCE.register(root, pf);\n        return root;\n\n    }\n\n    static Predicate<CommandSourceStack> subcommandPermission(String subcommandName, PermissionsFactory<CommandSourceStack> pf) {\n        final String permName = \"fastback.command.\" + subcommandName;\n        return pf.require(permName);\n    }\n\n    /**\n     * Retrieve a command argument. If they forgot to provide it, return null\n     * and log a helpful message rather than blowing up the world.  This is needed in the\n     * cases where the list of arguments is dynamic (e.g., retention policies) and we can't\n     * rely on brigadier's static parse trees.\n     */\n    static <V> V getArgumentNicely(final String argName, final Class<V> clazz, final CommandContext<?> cc, UserLogger log) {\n        try {\n            return cc.getArgument(argName, clazz);\n        } catch (IllegalArgumentException iae) {\n            missingArgument(argName, log);\n            return null;\n        }\n    }\n\n    static int missingArgument(final String argName, final CommandContext<CommandSourceStack> cc) {\n        return missingArgument(argName, UserLogger.ulog(cc));\n    }\n\n    static int missingArgument(final String argName, final UserLogger log) {\n        log.message(styledLocalized(\"fastback.chat.missing-argument\", ERROR, argName));\n        return FAILURE;\n    }\n\n    interface GitOp {\n        void execute(Repo repo) throws Exception;\n    }\n\n    static void gitOp(final ExecutionLock lock, final UserLogger ulog, final GitOp op) {\n        try {\n            executor().execute(lock, ulog, () -> {\n                final Path worldSaveDir = mod().getWorldDirectory();\n                final RepoFactory rf = RepoFactory.rf();\n                if (!rf.isGitRepo(worldSaveDir)) { // FIXME this is not the right place for these checks\n                    // If they haven't yet run 'backup init', make sure they've installed native.\n                    if (!isNativeOk(true, ulog, true)) return;\n                    ulog.message(styledLocalized(\"fastback.chat.not-enabled\", ERROR));\n                    return;\n                }\n                try (final Repo repo = rf.load(worldSaveDir)) {\n                    final GitConfig repoConfig = repo.getConfig();\n                    if (!isNativeOk(repoConfig, ulog, false)) return;\n                    if (!repoConfig.getBoolean(IS_BACKUP_ENABLED)) {\n                        ulog.message(styledLocalized(\"fastback.chat.not-enabled\", ERROR));\n                    } else {\n                        op.execute(repo);\n                    }\n                } catch (Exception e) {\n                    ulog.message(styledLocalized(\"fastback.chat.internal-error\", ERROR));\n                    syslog().error(e);\n                } finally {\n                    mod().clearHudText();\n                }\n            });\n        } catch (Exception e) {\n            ulog.internalError();\n            syslog().error(e);\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/CreateFileRemoteCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.lib.StoredConfig;\n\nimport java.nio.file.Path;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.missingArgument;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_FILE_REMOTE_BARE;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\nimport static net.pcal.fastback.common.utils.FileUtils.mkdirs;\n\nenum CreateFileRemoteCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"create-file-remote\";\n    private static final String ARGUMENT = \"file-path\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> missingArgument(ARGUMENT, cc)).\n                        then(argument(ARGUMENT, StringArgumentType.greedyString()).\n                                executes(CreateFileRemoteCommand::setFileRemote)\n                        )\n        );\n    }\n\n    private static int setFileRemote(final CommandContext<CommandSourceStack> cc) {\n        final UserLogger ulog = UserLogger.ulog(cc);\n        gitOp(NONE, ulog, repo -> {\n            final String targetPath = cc.getArgument(ARGUMENT, String.class);\n            final Path fupHome = Path.of(targetPath);\n            if (fupHome.toFile().exists()) {\n                ulog.message(styledLocalized(\"fastback.chat.create-file-remote-dir-exists\", ERROR, fupHome.toString()));\n                return;\n            }\n            mkdirs(fupHome);\n            GitConfig conf = repo.getConfig();\n            try (Git targetGit = Git.init().setBare(conf.getBoolean(IS_FILE_REMOTE_BARE)).setDirectory(fupHome.toFile()).call()) {\n                final StoredConfig targetGitc = targetGit.getRepository().getConfig();\n                targetGitc.setInt(\"pack\", null, \"window\", 0);\n                targetGitc.setInt(\"core\", null, \"bigFileThreshold\", 1);\n                targetGitc.save();\n            }\n            final String targetUrl = \"file://\" + fupHome.toAbsolutePath();\n            repo.getConfig().updater().set(REMOTE_PUSH_URL, targetUrl).save();\n            ulog.message(UserMessage.localized(\"fastback.chat.create-file-remote-created\", targetPath, targetUrl));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/DeleteCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.List;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.getArgumentNicely;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\nenum DeleteCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"delete\";\n    private static final String ARGUMENT = \"snapshot\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(literal(COMMAND_NAME).\n                requires(subcommandPermission(COMMAND_NAME, pf)).then(\n                        argument(ARGUMENT, StringArgumentType.string()).\n                                suggests(SnapshotNameSuggestions.local()).\n                                executes(DeleteCommand::delete)\n                )\n        );\n    }\n\n    private static int delete(final CommandContext<CommandSourceStack> cc) {\n        final UserLogger log = ulog(cc);\n        gitOp(WRITE, log, repo -> {\n            final String snapshotName = getArgumentNicely(ARGUMENT, String.class, cc.getLastChild(), log);\n            final SnapshotId sid = repo.createSnapshotId(snapshotName);\n            final String branchName = sid.getBranchName();\n            repo.deleteLocalBranches(List.of(branchName));\n            log.message(UserMessage.localized(\"fastback.chat.delete-done\", snapshotName));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/FullCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport java.io.IOException;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\n/**\n * Perform a local backup.\n *\n * @author pcal\n * @since 0.2.0\n */\nenum FullCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"full\";\n\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> run(cc.getSource()))\n        );\n    }\n\n    public static int run(CommandSourceStack scs) {\n        final UserLogger ulog = ulog(scs);\n        try {\n            saveWorldBeforeBackup(ulog);\n        } catch (IOException e) {\n            ulog.internalError();\n            syslog().error(e);\n        }\n        gitOp(WRITE, ulog, repo -> repo.doCommitAndPush(ulog));\n        return SUCCESS;\n    }\n\n    /**\n     * NOTE: this MUST be called in the game thread; calling it from one of our executor threads causes things\n     * to seize up (at least on shutdown backup?)\n     * <p>\n     * Workaround for https://github.com/pcal43/fastback/issues/112\n     */\n    static void saveWorldBeforeBackup(UserLogger ulog) throws IOException {\n        ulog.message(localized(\"fastback.chat.world-save\"));\n        mod().saveWorld();\n        ulog.message(localized(\"fastback.message.backing-up\"));\n    }\n}"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/GcCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\n\n/**\n * Runs garbage collection to try to free up disk space.\n *\n * @author pcal\n * @since 0.0.12\n */\nenum GcCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"gc\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(GcCommand::gc)\n        );\n    }\n\n    private static int gc(CommandContext<CommandSourceStack> cc) {\n        final UserLogger ulog = ulog(cc);\n        gitOp(WRITE, ulog, repo -> {\n            repo.doGc(ulog);\n            //log.chat(localized(\"fastback.chat.gc-done\", byteCountToDisplaySize(gc.getBytesReclaimed())));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/HelpCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport com.mojang.brigadier.suggestion.SuggestionProvider;\nimport com.mojang.brigadier.suggestion.Suggestions;\nimport com.mojang.brigadier.suggestion.SuggestionsBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\n\nimport java.io.StringWriter;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.FAILURE;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoFactory.rf;\n\nenum HelpCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"help\";\n    private static final String ARGUMENT = \"subcommand\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(HelpCommand::generalHelp).\n                        then(\n                                argument(ARGUMENT, StringArgumentType.word()).\n                                        suggests(new HelpTopicSuggestions()).\n                                        executes(this::subcommandHelp)\n                        )\n        );\n    }\n\n    private static class HelpTopicSuggestions implements SuggestionProvider<CommandSourceStack> {\n\n\n        HelpTopicSuggestions() {\n        }\n\n        @Override\n        public CompletableFuture<Suggestions> getSuggestions(final CommandContext<CommandSourceStack> cc,\n                                                             final SuggestionsBuilder builder) {\n            CompletableFuture<Suggestions> completableFuture = new CompletableFuture<>();\n            getSubcommandNames(cc).forEach(builder::suggest);\n            try {\n                completableFuture.complete(builder.buildFuture().get());\n            } catch (InterruptedException | ExecutionException e) {\n                syslog().error(\"looking up help topics\", e);\n                return null;\n            }\n            return completableFuture;\n        }\n    }\n\n    static int generalHelp(final CommandContext<CommandSourceStack> cc) {\n        try (final UserLogger ulog = ulog(cc)) {\n            StringWriter subcommands = null;\n            for (final String available : getSubcommandNames(cc)) {\n                if (subcommands == null) {\n                    subcommands = new StringWriter();\n                } else {\n                    subcommands.append(\", \");\n                }\n                subcommands.append(available);\n            }\n            ulog.message(UserMessage.localized(\"fastback.help.subcommands\", String.valueOf(subcommands)));\n            if (!rf().isGitRepo(mod().getWorldDirectory())) {\n                ulog.message(UserMessage.localized(\"fastback.help.suggest-init\"));\n            }\n            return SUCCESS;\n        }\n    }\n\n    private int subcommandHelp(final CommandContext<CommandSourceStack> cc) {\n        try (final UserLogger ulog = ulog(cc)) {\n            final String subcommand = cc.getLastChild().getArgument(ARGUMENT, String.class);\n            for (String available : getSubcommandNames(cc)) {\n                if (subcommand.equals(available)) {\n                    final String prefix = \"/backup \" + subcommand + \": \";\n                    ulog.message(UserMessage.localized(\"fastback.help.command.\" + subcommand, prefix));\n                    return SUCCESS;\n                }\n            }\n            ulog.message(styledLocalized(\"fastback.chat.invalid-input\", ERROR, subcommand));\n        }\n        return FAILURE;\n    }\n\n    private static List<String> getSubcommandNames(CommandContext<CommandSourceStack> cc) {\n        final List<String> out = new ArrayList<>();\n        cc.getNodes().get(0).getNode().getChildren().forEach(node -> out.add(node.getName()));\n        return out;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/InfoCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.config.GitConfigKey;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.retention.RetentionPolicy;\nimport net.pcal.fastback.common.retention.RetentionPolicyCodec;\nimport net.pcal.fastback.common.retention.RetentionPolicyType;\n\nimport java.util.function.Function;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.FAILURE;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_RETENTION_POLICY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.logging.UserMessage.raw;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoFactory.rf;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk;\nimport static org.apache.commons.io.FileUtils.byteCountToDisplaySize;\nimport static org.apache.commons.io.FileUtils.sizeOfDirectory;\n\n// TODO move this to Repo.doInfo\nenum InfoCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"info\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> info(cc.getSource()))\n        );\n    }\n\n    private static int info(final CommandSourceStack scs) {\n        requireNonNull(scs);\n        try (final UserLogger ulog = ulog(scs)) {\n            try {\n                ulog.message(UserMessage.localized(\"fastback.chat.info-header\"));\n                ulog.message(UserMessage.localized(\"fastback.chat.info-fastback-version\", mod().getModVersion()));\n                if (!rf().isGitRepo(mod().getWorldDirectory())) {\n                    // If they haven't yet run 'backup init', make sure they've installed native.\n                    if (!isNativeOk(true, ulog, true)) return FAILURE;\n                } else {\n                    try (final Repo repo = rf().load(mod().getWorldDirectory())) {\n                        final GitConfig conf = repo.getConfig();\n                        if (!isNativeOk(conf, ulog, true)) return FAILURE;\n                        ulog.message(UserMessage.localized(\"fastback.chat.info-uuid\", repo.getWorldId().toString()));\n                        // FIXME? this could be implemented more efficiently\n                        final long backupSize = sizeOfDirectory(repo.getDirectory());\n                        final long worldSize = sizeOfDirectory(repo.getWorkTree()) - backupSize;\n                        ulog.message(UserMessage.localized(\"fastback.chat.info-world-size\", byteCountToDisplaySize(worldSize)));\n                        ulog.message(UserMessage.localized(\"fastback.chat.info-backup-size\", byteCountToDisplaySize(backupSize)));\n\n                        show(IS_BACKUP_ENABLED, conf::getBoolean, ulog);\n                        show(REMOTE_PUSH_URL, conf::getString, ulog);\n                        show(RESTORE_DIRECTORY, conf::getString, ulog);\n                        show(AUTOBACK_WAIT_MINUTES, conf::getInt, ulog);\n                        show(IS_MODS_BACKUP_ENABLED, conf::getBoolean, ulog);\n                        show(BROADCAST_ENABLED, conf::getBoolean, ulog);\n                        show(BROADCAST_MESSAGE, conf::getString, ulog);\n\n                        final SchedulableAction shutdownAction = SchedulableAction.forConfigValue(conf.getString(SHUTDOWN_ACTION));\n                        ulog.message(UserMessage.localized(\"fastback.chat.info-shutdown-action\", getActionDisplay(shutdownAction)));\n                        final SchedulableAction autobackAction = SchedulableAction.forConfigValue(conf.getString(AUTOBACK_ACTION));\n                        ulog.message(UserMessage.localized(\"fastback.chat.info-autoback-action\", getActionDisplay(autobackAction)));\n\n                        showRetentionPolicy(ulog,\n                                conf.getString(LOCAL_RETENTION_POLICY),\n                                \"fastback.chat.retention-policy-set\",\n                                \"fastback.chat.retention-policy-none\"\n                        );\n                        showRetentionPolicy(ulog,\n                                conf.getString(REMOTE_RETENTION_POLICY),\n                                \"fastback.chat.remote-retention-policy-set\",\n                                \"fastback.chat.remote-retention-policy-none\"\n                        );\n                    }\n                }\n            } catch (final Exception e) {\n                ulog.internalError(e);\n            }\n        }\n        return SUCCESS;\n    }\n\n    private static void show(GitConfigKey key, Function<GitConfigKey, Object> valueFn, UserLogger ulog) {\n        ulog.message(raw(key.getDisplayName() + \" = \" + valueFn.apply(key)));\n    }\n\n    private static String getActionDisplay(SchedulableAction action) {\n        return action == null ? SchedulableAction.NONE.getArgumentName() : action.getArgumentName();\n    }\n\n    private static void showRetentionPolicy(UserLogger log, String encodedPolicy, String setKey, String noneKey) {\n        if (encodedPolicy == null) {\n            log.message(UserMessage.localized(noneKey));\n        } else {\n            final RetentionPolicy policy = RetentionPolicyCodec.INSTANCE.\n                    decodePolicy(RetentionPolicyType.getAvailable(), encodedPolicy);\n            if (policy == null) {\n                log.message(UserMessage.localized(noneKey));\n            } else {\n                log.message(UserMessage.localized(setKey));\n                log.message(policy.getDescription());\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/InitCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.RepoFactory;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\nimport static net.pcal.fastback.common.utils.Executor.executor;\n\n/**\n * @author pcal\n * @since 0.15.0\n */\nenum InitCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"init\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(InitCommand::init)\n        );\n    }\n\n    private static int init(final CommandContext<CommandSourceStack> cc) {\n        try (final UserLogger ulog = ulog(cc)) {\n            executor().execute(NONE, ulog, () -> {\n                        final Path worldSaveDir = mod().getWorldDirectory();\n                        final RepoFactory rf = RepoFactory.rf();\n                        try {\n                            rf.doInit(worldSaveDir, ulog);\n                        } catch (IOException e) {\n                            throw new RuntimeException(e);\n                        }\n                    }\n            );\n        }\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/ListCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.FAILURE;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoFactory.rf;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\nenum ListCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"list\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(this::execute)\n        );\n    }\n\n    private int execute(final CommandContext<CommandSourceStack> cc) {\n        try (final UserLogger ulog = UserLogger.ulog(cc)) {\n            if (!rf().doInitCheck(mod().getWorldDirectory(), ulog)) return FAILURE;\n            gitOp(NONE, ulog, repo -> {\n                final List<SnapshotId> snapshots = new ArrayList<>(repo.getLocalSnapshots());\n                Collections.sort(snapshots);\n                for (final SnapshotId sid : snapshots) {\n                    ulog.message(UserMessage.raw(sid.getShortName()));\n                }\n            });\n        }\n        return SUCCESS;\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/LocalCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.FAILURE;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.commands.FullCommand.saveWorldBeforeBackup;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoFactory.rf;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\n/**\n * Perform a local backup.\n *\n * @author pcal\n * @since 0.2.0\n */\nenum LocalCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"local\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> run(cc.getSource()))\n        );\n    }\n\n    private static int run(CommandSourceStack scs) {\n        try (final UserLogger ulog = ulog(scs)) {\n            if (!rf().doInitCheck(mod().getWorldDirectory(), ulog)) return FAILURE;\n            try {\n                saveWorldBeforeBackup(ulog);\n            } catch (Exception e) {\n                ulog.internalError();\n                syslog().error(e);\n            }\n            gitOp(WRITE, ulog, repo -> repo.doCommitSnapshot(ulog));\n        }\n        return SUCCESS;\n    }\n}"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/PermissionsFactory.java",
    "content": "package net.pcal.fastback.common.commands;\n\n/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\nimport java.util.function.Predicate;\n\npublic interface PermissionsFactory<S> {\n\n    Predicate<S> require(String permissionName);\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/PruneCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.Collection;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\n/**\n * Command to prune all snapshots that are not to be retained per the retention policy.\n *\n * @author pcal\n * @since 0.2.0\n */\nenum PruneCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"prune\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> prune(cc.getSource()))\n        );\n    }\n\n    private static int prune(final CommandSourceStack scs) {\n        final UserLogger ulog = ulog(scs);\n        gitOp(WRITE, ulog, repo -> {\n            final Collection<SnapshotId> pruned = repo.doLocalPrune(ulog);\n            if (pruned != null) {\n                ulog.message(UserMessage.localized(\"fastback.chat.prune-done\", pruned.size()));\n                if (!pruned.isEmpty()) ulog.message(UserMessage.localized(\"fastback.chat.prune-suggest-gc\"));\n            }\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/PushCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.getArgumentNicely;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\n\n/**\n * @author pcal\n * @since 0.15.0\n */\nenum PushCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"push\";\n    private static final String ARGUMENT = \"snapshot-date\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(literal(COMMAND_NAME).\n                requires(subcommandPermission(COMMAND_NAME, pf)).then(\n                        argument(ARGUMENT, StringArgumentType.string()).\n                                suggests(SnapshotNameSuggestions.local()).\n                                executes(PushCommand::execute)\n                )\n        );\n    }\n\n    private static int execute(CommandContext<CommandSourceStack> cc) {\n        final UserLogger log = UserLogger.ulog(cc);\n        gitOp(NONE, log, repo -> {\n            final String snapshotName = getArgumentNicely(ARGUMENT, String.class, cc.getLastChild(), log);\n            final SnapshotId sid = repo.createSnapshotId(snapshotName);\n            repo.doPushSnapshot(sid, log);\n        });\n        return SUCCESS;\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/RemoteDeleteCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\nenum RemoteDeleteCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"remote-delete\";\n    private static final String ARGUMENT = \"snapshot\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(literal(COMMAND_NAME).\n                requires(subcommandPermission(COMMAND_NAME, pf)).then(\n                        argument(ARGUMENT, StringArgumentType.string()).\n                                suggests(SnapshotNameSuggestions.remote()).\n                                executes(RemoteDeleteCommand::delete)\n                )\n        );\n    }\n\n    private static int delete(CommandContext<CommandSourceStack> cc) {\n        final UserLogger log = ulog(cc);\n        gitOp(WRITE, log, repo -> {\n            final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class);\n            final SnapshotId sid = repo.createSnapshotId(snapshotName);\n            repo.deleteRemoteBranch(sid.getBranchName());\n            log.message(UserMessage.localized(\"fastback.chat.remote-delete-done\", snapshotName));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/RemoteListCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\nenum RemoteListCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"remote-list\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(RemoteListCommand::execute)\n        );\n    }\n\n    private static int execute(final CommandContext<CommandSourceStack> cc) {\n        final UserLogger log = UserLogger.ulog(cc);\n        gitOp(NONE, log, repo -> {\n            final List<SnapshotId> snapshots = new ArrayList<>(repo.getRemoteSnapshots());\n            Collections.sort(snapshots);\n            snapshots.forEach(sid -> log.message(UserMessage.raw(sid.getShortName())));\n            log.message(UserMessage.localized(\"fastback.chat.remote-list-done\", snapshots.size(), repo.getConfig().getString(REMOTE_PUSH_URL)));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/RemotePruneCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.Collection;\n\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE;\n\n/**\n * Command to prune all snapshots that are not to be retained per the retention policy.\n *\n * @author pcal\n * @since 0.2.0\n */\nenum RemotePruneCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"remote-prune\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).\n                        executes(cc -> remotePrune(cc.getSource()))\n        );\n    }\n\n    private static int remotePrune(final CommandSourceStack scs) {\n        final UserLogger ulog = ulog(scs);\n        gitOp(WRITE, ulog, repo -> {\n            final Collection<SnapshotId> pruned = repo.doRemotePrune(ulog);\n            ulog.message(UserMessage.localized(\"fastback.chat.prune-done\", pruned.size()));\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/RemoteRestoreCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\nenum RemoteRestoreCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"remote-restore\";\n    private static final String ARGUMENT = \"snapshot\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).then(\n                                argument(ARGUMENT, StringArgumentType.string()).\n                                        suggests(SnapshotNameSuggestions.remote()).\n                                        executes(RemoteRestoreCommand::remoteRestore)\n                        )\n        );\n    }\n\n    private static int remoteRestore(final CommandContext<CommandSourceStack> cc) {\n        final UserLogger ulog = ulog(cc);\n        gitOp(NONE, ulog, repo -> {\n            final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class);\n            repo.doRestoreRemoteSnapshot(snapshotName, ulog);\n        });\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/RestoreCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\nenum RestoreCommand implements Command {\n\n    INSTANCE;\n\n    private static final String COMMAND_NAME = \"restore\";\n    private static final String ARGUMENT = \"snapshot\";\n\n    @Override\n    public void register(LiteralArgumentBuilder<CommandSourceStack> argb, PermissionsFactory<CommandSourceStack> pf) {\n        argb.then(\n                literal(COMMAND_NAME).\n                        requires(subcommandPermission(COMMAND_NAME, pf)).then(\n                                argument(ARGUMENT, StringArgumentType.string()).\n                                        suggests(SnapshotNameSuggestions.local()).\n                                        executes(RestoreCommand::restore)\n                        )\n        );\n    }\n\n    private static int restore(final CommandContext<CommandSourceStack> cc) {\n        try (final UserLogger ulog = UserLogger.ulog(cc)) {\n            gitOp(NONE, ulog, repo -> {\n                final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class);\n                repo.doRestoreLocalSnapshot(snapshotName, ulog);\n            });\n        }\n        return SUCCESS;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/SchedulableAction.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport net.pcal.fastback.common.config.FastbackConfigKey;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.Repo;\n\nimport java.util.concurrent.Callable;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * Encapsulates an action that can be performed in response to events such as shutdown or autosaving.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic enum SchedulableAction {\n\n    NONE(\"none\") {\n        @Override\n        public Callable<Void> getTask(final Repo repo, final UserLogger ulog) {\n            return () -> null;\n        }\n    },\n\n    LOCAL(\"local\") {\n        @Override\n        public Callable<Void> getTask(final Repo repo, final UserLogger ulog) {\n            return () -> {\n                repo.doCommitSnapshot(ulog);\n                return null;\n            };\n        }\n    },\n\n    FULL(\"full\") {\n        @Override\n        public Callable<Void> getTask(final Repo repo, final UserLogger ulog) {\n            return () -> {\n                repo.doCommitAndPush(ulog);\n                return null;\n            };\n        }\n    },\n\n    FULL_GC(\"full-gc\") {\n        @Override\n        public Callable<Void> getTask(final Repo repo, final UserLogger ulog) {\n            return () -> {\n                repo.doCommitAndPush(ulog);\n                repo.doLocalPrune(ulog);\n                repo.doGc(ulog);\n                return null;\n            };\n        }\n    };\n\n    public static SchedulableAction forConfigValue(final GitConfig c, final FastbackConfigKey key) {\n        String configValue = c.getString(key);\n        if (configValue == null) return null;\n        return forConfigValue(configValue);\n    }\n\n    public static SchedulableAction forConfigValue(String configValue) {\n        if (configValue == null) return null;\n        for (SchedulableAction action : SchedulableAction.values()) {\n            if (action.configValue.equals(configValue)) {\n                return action == SchedulableAction.NONE ? null : action;\n            }\n        }\n        return null;\n    }\n\n    private final String configValue;\n\n    SchedulableAction(String configValue) {\n        this.configValue = requireNonNull(configValue);\n    }\n\n    public String getConfigValue() {\n        return this.configValue;\n    }\n\n    public String getArgumentName() {\n        return this.configValue;\n    }\n\n    public abstract Callable<?> getTask(Repo repo, UserLogger ulog);\n}\n\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/SetCommand.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.arguments.IntegerArgumentType;\nimport com.mojang.brigadier.arguments.StringArgumentType;\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.config.FastbackConfigKey;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.config.GitConfigKey;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.repo.RepoFactory;\nimport net.pcal.fastback.common.retention.RetentionPolicy;\nimport net.pcal.fastback.common.retention.RetentionPolicyCodec;\nimport net.pcal.fastback.common.retention.RetentionPolicyType;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static net.minecraft.commands.Commands.argument;\nimport static net.minecraft.commands.Commands.literal;\nimport static net.pcal.fastback.common.commands.Commands.FAILURE;\nimport static net.pcal.fastback.common.commands.Commands.SUCCESS;\nimport static net.pcal.fastback.common.commands.Commands.getArgumentNicely;\nimport static net.pcal.fastback.common.commands.Commands.missingArgument;\nimport static net.pcal.fastback.common.commands.Commands.subcommandPermission;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_LOCK_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_RETENTION_POLICY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserLogger.ulog;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.logging.UserMessage.raw;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoFactory.rf;\n\n/**\n * Sets various configuration values.\n *\n * @author pcal\n * @since 0.13.0\n */\nenum SetCommand implements Command {\n\n    INSTANCE;\n\n    // ======================================================================\n    // Command implementation\n\n    private static final String COMMAND_NAME = \"set\";\n\n    @Override\n    public void register(final LiteralArgumentBuilder<CommandSourceStack> root, PermissionsFactory<CommandSourceStack> pf) {\n        final LiteralArgumentBuilder<CommandSourceStack> sc = literal(COMMAND_NAME).\n                requires(subcommandPermission(COMMAND_NAME, pf)).\n                executes(cc -> missingArgument(\"key\", cc));\n        registerBooleanConfigValue(IS_LOCK_CLEANUP_ENABLED, sc);\n        registerBooleanConfigValue(IS_BACKUP_ENABLED, sc);\n        registerBooleanConfigValue(IS_MODS_BACKUP_ENABLED, sc);\n        registerBooleanConfigValue(BROADCAST_ENABLED, sc);\n        registerStringConfigValue(BROADCAST_MESSAGE, \"message\", sc);\n        registerStringConfigValue(RESTORE_DIRECTORY, \"full-directory-path\", sc);\n        registerStringConfigValue(REMOTE_PUSH_URL, \"url\", sc);\n        registerIntegerConfigValue(AUTOBACK_WAIT_MINUTES, \"minutes\", sc);\n\n        {\n            final List<String> schedulableActions = new ArrayList<>();\n            for (final SchedulableAction sa : SchedulableAction.values()) {\n                schedulableActions.add(sa.getConfigValue());\n            }\n            registerSelectConfigValue(AUTOBACK_ACTION, schedulableActions, sc);\n            registerSelectConfigValue(SHUTDOWN_ACTION, schedulableActions, sc);\n        }\n\n        registerSetRetentionCommand(LOCAL_RETENTION_POLICY, sc);\n        registerSetRetentionCommand(REMOTE_RETENTION_POLICY, sc);\n\n        registerForceDebug(sc);\n        root.then(sc);\n    }\n\n\n    // ======================================================================\n    // Boolean config values\n\n    private static void registerBooleanConfigValue(final GitConfigKey key, final LiteralArgumentBuilder<CommandSourceStack> setCommand) {\n        final LiteralArgumentBuilder<CommandSourceStack> builder = literal(key.getDisplayName());\n        builder.then(literal(\"true\").executes(cc -> setBooleanConfigValue(key, true, cc)));\n        builder.then(literal(\"false\").executes(cc -> setBooleanConfigValue(key, false, cc)));\n        setCommand.then(builder);\n    }\n\n    private static int setBooleanConfigValue(final GitConfigKey key, final boolean newValue, final CommandContext<CommandSourceStack> cc) {\n        try (UserLogger ulog = ulog(cc)) {\n            final Path worldSaveDir = mod().getWorldDirectory();\n            final RepoFactory rf = rf();\n            if (rf.isGitRepo(worldSaveDir)) {\n                try (Repo repo = rf.load(worldSaveDir)) {\n                    final GitConfig conf = repo.getConfig();\n                    boolean current = conf.getBoolean(key);\n                    if (current == newValue) {\n                        ulog.message(localized(\"fastback.chat.no-change\"));\n                    } else {\n                        repo.getConfig().updater().set(key, newValue).save();\n                        ulog.message(raw(key.getDisplayName() + \" = \" + newValue));\n                    }\n                } catch (Exception e) {\n                    ulog.internalError(e);\n                    return FAILURE;\n                }\n            }\n        }\n        return SUCCESS;\n    }\n\n    // ======================================================================\n    // Integer config values\n\n    private static void registerIntegerConfigValue(final GitConfigKey key, final String argName, final LiteralArgumentBuilder<CommandSourceStack> setCommand) {\n        final LiteralArgumentBuilder<CommandSourceStack> builder = literal(key.getDisplayName());\n        builder.then(argument(argName, IntegerArgumentType.integer()).\n                executes(cc -> setIntegerConfigValue(key, argName, cc)));\n        setCommand.then(builder);\n    }\n\n    private static int setIntegerConfigValue(final GitConfigKey key, final String argName, final CommandContext<CommandSourceStack> cc) {\n        try (UserLogger ulog = ulog(cc)) {\n            final Path worldSaveDir = mod().getWorldDirectory();\n            final RepoFactory rf = rf();\n            if (rf.isGitRepo(worldSaveDir)) {\n                try (Repo repo = rf.load(worldSaveDir)) {\n                    final Integer newValue = cc.getArgument(argName, Integer.class);\n                    repo.getConfig().updater().set(key, newValue).save();\n                    ulog.message(raw(key.getDisplayName() + \" = \" + newValue));\n                } catch (Exception e) {\n                    ulog.internalError(e);\n                    return FAILURE;\n                }\n            }\n        }\n        return SUCCESS;\n    }\n\n    // ======================================================================\n    // String config values\n\n    private static void registerStringConfigValue(final GitConfigKey key, final String argName, final LiteralArgumentBuilder<CommandSourceStack> setCommand) {\n        final LiteralArgumentBuilder<CommandSourceStack> builder = literal(key.getDisplayName());\n        builder.then(argument(argName, StringArgumentType.greedyString()).\n                executes(cc -> setStringConfigValue(key, argName, cc)));\n        setCommand.then(builder);\n    }\n\n    private static int setStringConfigValue(final GitConfigKey key, final String argName, final CommandContext<CommandSourceStack> cc) {\n        try (UserLogger ulog = ulog(cc)) {\n            final Path worldSaveDir = mod().getWorldDirectory();\n            final RepoFactory rf = rf();\n            if (rf.isGitRepo(worldSaveDir)) {\n                try (Repo repo = rf.load(worldSaveDir)) {\n                    final String newValue = cc.getArgument(argName, String.class);\n                    repo.getConfig().updater().set(key, newValue).save();\n                    ulog.message(raw(key.getDisplayName() + \" = \" + newValue));\n                } catch (Exception e) {\n                    ulog.internalError(e);\n                    return FAILURE;\n                }\n            }\n        }\n        return SUCCESS;\n    }\n\n    // ======================================================================\n    // Selection config values\n\n    private static void registerSelectConfigValue(GitConfigKey key, List<String> selections, final LiteralArgumentBuilder<CommandSourceStack> setCommand) {\n        final LiteralArgumentBuilder<CommandSourceStack> builder = literal(key.getDisplayName());\n        for (final String selection : selections) {\n            builder.then(literal(selection).executes(cc -> setSelectionConfigValue(key, selection, cc)));\n        }\n        setCommand.then(builder);\n    }\n\n    private static int setSelectionConfigValue(final GitConfigKey key, final String newValue, final CommandContext<CommandSourceStack> cc) {\n        try (UserLogger ulog = ulog(cc)) {\n            final Path worldSaveDir = mod().getWorldDirectory();\n            if (rf().isGitRepo(worldSaveDir)) {\n                try (final Repo repo = rf().load(worldSaveDir)) {\n                    repo.getConfig().updater().set(key, newValue).save();\n                    ulog.message(raw(key.getDisplayName() + \" = \" + newValue));\n                } catch (Exception e) {\n                    ulog.internalError(e);\n                    return FAILURE;\n                }\n            }\n        }\n        return SUCCESS;\n    }\n\n    // ======================================================================\n    // force-debug\n\n    private static final String FORCE_DEBUG_SETTING = \"force-debug-enabled\";\n\n    private static void registerForceDebug(final LiteralArgumentBuilder<CommandSourceStack> setCommand) {\n        final LiteralArgumentBuilder<CommandSourceStack> debug = literal(FORCE_DEBUG_SETTING);\n        debug.then(literal(\"true\").executes(cc -> setForceDebug(cc, true)));\n        debug.then(literal(\"false\").executes(cc -> setForceDebug(cc, false)));\n        setCommand.then(debug);\n    }\n\n    private static int setForceDebug(final CommandContext<CommandSourceStack> cc, boolean value) {\n        syslog().setForceDebugEnabled(value);\n        try (final UserLogger ulog = ulog(cc)) {\n            ulog.message(raw(\"force-debug-enabled = \" + value));\n        }\n        return SUCCESS;\n    }\n\n\n    // ======================================================================\n    // Retention policy commands\n\n    /**\n     * Register a 'set retention' command that tab completes with all the policies and the policy arguments.\n     * Broken out as a helper methods so this logic can be shared by set-retention and set-remote-retention.\n     * <p>\n     * FIXME? The command parsing here could be more user-friendly.  Not really clear how to implement\n     * argument defaults.  Also a lot of noise from bugs like this: https://bugs.mojang.com/browse/MC-165562\n     * Just generally not sure how to beat brigadier into submission here.\n     */\n    private static void registerSetRetentionCommand(final FastbackConfigKey key,\n                                                    final LiteralArgumentBuilder<CommandSourceStack> argb) {\n        final LiteralArgumentBuilder<CommandSourceStack> retainCommand = literal(key.getSettingName());\n        for (final RetentionPolicyType rpt : RetentionPolicyType.getAvailable()) {\n            final LiteralArgumentBuilder<CommandSourceStack> policyCommand = literal(rpt.getCommandName());\n            policyCommand.executes(cc -> setRetentionPolicy(cc, rpt, key));\n            if (rpt.getParameters() != null) {\n                for (RetentionPolicyType.Parameter<?> param : rpt.getParameters()) {\n                    policyCommand.then(argument(param.name(), param.type()).\n                            executes(cc -> setRetentionPolicy(cc, rpt, key)));\n                }\n            }\n            retainCommand.then(policyCommand);\n        }\n        argb.then(retainCommand);\n    }\n\n\n    /**\n     * Does the work to encode a policy configuration and set it in git configuration.\n     * Broken out as a helper methods so this logic can be shared by set-retention and set-remote-retention.\n     * <p>\n     * TODO this should probably move to Repo.\n     */\n    public static int setRetentionPolicy(final CommandContext<CommandSourceStack> cc,\n                                         final RetentionPolicyType rpt,\n                                         final FastbackConfigKey confKey) {\n        final UserLogger ulog = ulog(cc);\n        final Path worldSaveDir = mod().getWorldDirectory();\n        try (final Repo repo = rf().load(worldSaveDir)) {\n            final Map<String, String> config = new HashMap<>();\n            for (final RetentionPolicyType.Parameter<?> p : rpt.getParameters()) {\n                final Object val = getArgumentNicely(p.name(), p.clazz(), cc, ulog);\n                if (val == null) return FAILURE;\n                config.put(p.name(), String.valueOf(val));\n            }\n            final String encodedPolicy = RetentionPolicyCodec.INSTANCE.encodePolicy(rpt, config);\n            final RetentionPolicy rp =\n                    RetentionPolicyCodec.INSTANCE.decodePolicy(RetentionPolicyType.getAvailable(), encodedPolicy);\n            if (rp == null) {\n                syslog().error(\"Failed to decode policy \" + encodedPolicy, new Exception());\n                return FAILURE;\n            }\n            final GitConfig conf = repo.getConfig();\n            conf.updater().set(confKey, encodedPolicy).save();\n            ulog.message(localized(\"fastback.chat.retention-policy-set\"));\n            ulog.message(rp.getDescription());\n            return SUCCESS;\n        } catch (Exception e) {\n            syslog().error(\"Failed to set retention policy\", e);\n            return FAILURE;\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/commands/SnapshotNameSuggestions.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.common.commands;\n\nimport com.mojang.brigadier.context.CommandContext;\nimport com.mojang.brigadier.suggestion.SuggestionProvider;\nimport com.mojang.brigadier.suggestion.Suggestions;\nimport com.mojang.brigadier.suggestion.SuggestionsBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.Iterator;\nimport java.util.concurrent.CompletableFuture;\n\nimport static net.pcal.fastback.common.commands.Commands.gitOp;\nimport static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE;\n\nabstract class SnapshotNameSuggestions implements SuggestionProvider<CommandSourceStack> {\n\n    static SnapshotNameSuggestions local() {\n        return new SnapshotNameSuggestions() {\n            @Override\n            protected Iterator<SnapshotId> getSnapshotIds(Repo repo, UserLogger ulog) throws Exception {\n                return repo.getLocalSnapshots().iterator();\n            }\n        };\n    }\n\n    static SnapshotNameSuggestions remote() {\n        return new SnapshotNameSuggestions() {\n            @Override\n            protected Iterator<SnapshotId> getSnapshotIds(Repo repo, UserLogger ulog) throws Exception {\n                return repo.getRemoteSnapshots().iterator();\n            }\n        };\n    }\n\n    @Override\n    public CompletableFuture<Suggestions> getSuggestions(final CommandContext<CommandSourceStack> cc,\n                                                         final SuggestionsBuilder builder) {\n        CompletableFuture<Suggestions> completableFuture = new CompletableFuture<>();\n        try (final UserLogger ulog = UserLogger.ulog(cc)) {\n            gitOp(NONE, ulog, repo -> {\n                final Iterator<SnapshotId> i = getSnapshotIds(repo, ulog);\n                // Note to self: there's no point sorting here because the mc code (Suggestion.java) is\n                // going to resort it anyway.\n                while (i.hasNext()) builder.suggest(i.next().getShortName());\n                completableFuture.complete(builder.buildFuture().get());\n            });\n        }\n        return completableFuture;\n    }\n\n    abstract protected Iterator<SnapshotId> getSnapshotIds(Repo repo, UserLogger log) throws Exception;\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/config/FastbackConfigKey.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.config;\n\n/**\n * .gitconfig settings that fastback cares about.\n *\n * @author pcal\n */\npublic enum FastbackConfigKey implements GitConfigKey {\n\n    AUTOBACK_ACTION(\"autoback-action\", null),\n    AUTOBACK_WAIT_MINUTES(\"autoback-wait\", 0),\n    BROADCAST_ENABLED(\"broadcast-enabled\", true),\n    BROADCAST_MESSAGE(\"broadcast-message\", null),\n    IS_BACKUP_ENABLED(\"backup-enabled\", true),\n    IS_BRANCH_CLEANUP_ENABLED(true),\n    IS_FILE_REMOTE_BARE(true),\n    IS_LOCK_CLEANUP_ENABLED(\"lock-cleanup-enabled\", true),\n    IS_NATIVE_GIT_ENABLED(\"native-git-enabled\", true),\n    IS_MODS_BACKUP_ENABLED(\"mods-backup-enabled\", false),\n    IS_REFLOG_DELETION_ENABLED(true),\n    IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED(true),\n    IS_SMART_PUSH_ENABLED(\"smart-push-enabled\", false),\n    IS_TEMP_BRANCH_CLEANUP_ENABLED(true),\n    IS_TRACKING_BRANCH_CLEANUP_ENABLED(true),\n    IS_UUID_CHECK_ENABLED(true),\n    LOCAL_RETENTION_POLICY(\"retention-policy\", null),\n    REMOTE_NAME(\"remote-name\", \"origin\"),\n    REMOTE_RETENTION_POLICY(\"remote-retention-policy\", null),\n    RESTORE_DIRECTORY(\"restore-directory\", null),\n    SHUTDOWN_ACTION(\"shutdown-action\", \"local\"),\n    UPDATE_GITATTRIBUTES_ENABLED(\"update-gitattributes-enabled\", true),\n    UPDATE_GITIGNORE_ENABLED(\"update-gitignore-enabled\", true);\n\n    private final String settingName;\n    private final Boolean booleanDefault;\n    private final String stringDefault;\n    private final Integer intDefault;\n\n    FastbackConfigKey(boolean booleanDefaultValue) {\n        this(null, booleanDefaultValue, null, null);\n    }\n\n    FastbackConfigKey(final String settingName, boolean booleanDefaultValue) {\n        this(settingName, booleanDefaultValue, null, null);\n    }\n\n    FastbackConfigKey(final String settingName, String stringDefaultValue) {\n        this(settingName, null, stringDefaultValue, null);\n    }\n\n    FastbackConfigKey(final String settingName, int intDefault) {\n        this(settingName, null, null, intDefault);\n    }\n\n    FastbackConfigKey(final String settingName, final Boolean booleanDefault, String stringDefault, Integer intDefault) {\n        this.settingName = settingName; //requireNonNull(settingName);\n        this.booleanDefault = booleanDefault;\n        this.stringDefault = stringDefault;\n        this.intDefault = intDefault;\n    }\n\n\n    @Override\n    public String getSectionName() {\n        return \"fastback\";\n    }\n\n    @Override\n    public String getSubSectionName() {\n        return null;\n    }\n\n    @Override\n    public String getSettingName() {\n        return this.settingName;\n    }\n\n    @Override\n    public boolean getBooleanDefault() {\n        if (this.booleanDefault == null) throw new IllegalStateException(this + \" is not a boolean\");\n        return this.booleanDefault;\n    }\n\n    @Override\n    public String getStringDefault() {\n        return this.stringDefault;\n    }\n\n    @Override\n    public int getIntDefault() {\n        if (this.intDefault == null) throw new IllegalStateException(this + \" is not an int\");\n        return this.intDefault;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/config/GitConfig.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.config;\n\nimport org.eclipse.jgit.api.Git;\n\nimport java.io.IOException;\n\n/**\n * Abstract representation of a git worktree's configuration.\n *\n * @author pcal\n */\npublic interface GitConfig {\n\n    // @Deprecated - eventually we want the public contract to stop being\n    // coupled to jgit\n    static GitConfig load(Git jgit) {\n        return GitConfigImpl.load(jgit);\n    }\n\n    boolean getBoolean(GitConfigKey key);\n\n    String getString(GitConfigKey key);\n\n    int getInt(GitConfigKey key);\n\n    boolean isSet(GitConfigKey key);\n\n    Updater updater();\n\n    /**\n     * Helper for updating the local .git/config file.\n     */\n    interface Updater {\n\n        Updater set(GitConfigKey key, boolean newValue);\n\n        Updater set(GitConfigKey key, String newValue);\n\n        Updater set(GitConfigKey key, int newValue);\n\n        Updater setCommented(GitConfigKey key, boolean newValue);\n\n        Updater setCommented(GitConfigKey key, String newValue);\n\n        Updater setCommented(GitConfigKey key, int newValue);\n\n        void save() throws IOException;\n    }\n}"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/config/GitConfigImpl.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.config;\n\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.lib.StoredConfig;\n\nimport java.io.IOException;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * JGit-based implementation of GitConfig.\n *\n * @author pcal\n */\nclass GitConfigImpl implements GitConfig {\n\n    static GitConfig load(final Git jgit) {\n        return new GitConfigImpl(jgit.getRepository().getConfig());\n    }\n\n    public final StoredConfig storedConfig;\n\n    GitConfigImpl(StoredConfig jgitConfig) {\n        this.storedConfig = requireNonNull(jgitConfig);\n    }\n\n    @Override\n    public boolean getBoolean(GitConfigKey key) {\n        if (key.getSettingName() == null) return key.getBooleanDefault();\n        return storedConfig.getBoolean(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), key.getBooleanDefault());\n    }\n\n    @Override\n    public String getString(GitConfigKey key) {\n        if (key.getSettingName() == null) return key.getStringDefault();\n        final String out = storedConfig.getString(key.getSectionName(), key.getSubSectionName(), key.getSettingName());\n        return out != null ? out : key.getStringDefault();\n    }\n\n    @Override\n    public int getInt(GitConfigKey key) {\n        if (key.getSettingName() == null) return key.getIntDefault();\n        return storedConfig.getInt(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), key.getIntDefault());\n    }\n\n    @Override\n    public boolean isSet(GitConfigKey key) {\n        final String out = storedConfig.getString(key.getSectionName(), key.getSubSectionName(), key.getSettingName());\n        return out != null;\n    }\n\n    @Override\n    public Updater updater() {\n        return new UpdaterImpl();\n    }\n\n    private class UpdaterImpl implements Updater {\n\n        @Override\n        public Updater set(GitConfigKey key, boolean newValue) {\n            storedConfig.setBoolean(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue);\n            return this;\n        }\n\n        @Override\n        public Updater set(GitConfigKey key, String newValue) {\n            storedConfig.setString(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue);\n            return this;\n        }\n\n        @Override\n        public Updater set(GitConfigKey key, int newValue) {\n            storedConfig.setInt(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue);\n            return this;\n        }\n\n        // ======================================================================\n        // Methods for adding commented-out settings.  Useful for making the\n        // initial git config a little more self-documenting.  jgit evidently\n        // doesn't know the difference.\n\n        @Override\n        public Updater setCommented(GitConfigKey key, boolean newValue) {\n            storedConfig.setBoolean(key.getSectionName(), key.getSubSectionName(), \"# \" + key.getSettingName(), newValue);\n            return this;\n        }\n\n        @Override\n        public Updater setCommented(GitConfigKey key, String newValue) {\n            storedConfig.setString(key.getSectionName(), key.getSubSectionName(), \"# \" + key.getSettingName(), newValue);\n            return this;\n        }\n\n        @Override\n        public Updater setCommented(GitConfigKey key, int newValue) {\n            storedConfig.setInt(key.getSectionName(), key.getSubSectionName(), \"# \" + key.getSettingName(), newValue);\n            return this;\n        }\n\n        @Override\n        public void save() throws IOException {\n            storedConfig.save();\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/config/GitConfigKey.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.config;\n\n/**\n * @author pcal\n * @since 0.14.0\n */\npublic interface GitConfigKey {\n\n    String getSectionName();\n\n    String getSubSectionName();\n\n    String getSettingName();\n\n    boolean getBooleanDefault();\n\n    String getStringDefault();\n\n    default String getDisplayName() {\n        return this.getSettingName();\n    }\n\n    int getIntDefault();\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/config/OtherConfigKey.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.config;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * .gitconfig settings that fastback cares about.\n *\n * @author pcal\n */\npublic enum OtherConfigKey implements GitConfigKey {\n\n    REMOTE_PUSH_URL(\"remote\", \"origin\", \"url\") {\n        @Override\n        public String getDisplayName() {\n            return \"remote-url\";\n        }\n    },\n\n    /**\n     * We disable commit signing on git init.  https://github.com/pcal43/fastback/issues/165\n     */\n    COMMIT_SIGNING_ENABLED(\"commit\", null, \"gpgsign\"),\n\n    // Allow reading the user's name and email from .gitconfig\n    USER_NAME(\"user\", null, \"name\"),\n    USER_EMAIL(\"user\", null, \"email\");\n\n    private final String sectionName, subSectionName, settingName;\n\n    OtherConfigKey(final String sectionName,\n                   final String subsectionName,\n                   final String settingName) {\n        this.sectionName = requireNonNull(sectionName);\n        this.subSectionName = subsectionName;\n        this.settingName = requireNonNull(settingName);\n    }\n\n    @Override\n    public String getSectionName() {\n        return this.sectionName;\n    }\n\n    @Override\n    public String getSubSectionName() {\n        return this.subSectionName;\n    }\n\n    @Override\n    public String getSettingName() {\n        return this.settingName;\n    }\n\n    @Override\n    public boolean getBooleanDefault() {\n        return false;\n    }\n\n    @Override\n    public String getStringDefault() {\n        return null;\n    }\n\n    @Override\n    public int getIntDefault() {\n        return 0;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/AutosaveLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Handles messages in the context of an autosave operation.\n *\n * @author pcal\n * @since 0.15.0\n */\nenum AutosaveLogger implements UserLogger {\n\n    INSTANCE;\n\n    @Override\n    public void message(final UserMessage message) {\n    }\n\n    @Override\n    public void update(final UserMessage message) {\n        mod().setHudText(message);\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/CommandLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport net.minecraft.commands.CommandSourceStack;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Handles messages in the context of a command executed by the user in the console or chat box.\n *\n * @author pcal\n * @since 0.15.0\n */\nclass CommandLogger implements UserLogger {\n\n    private final CommandSourceStack scs;\n\n    CommandLogger(final CommandSourceStack scs) {\n        this.scs = requireNonNull(scs);\n    }\n\n    @Override\n    public void message(final UserMessage message) {\n        mod().sendChat(message, this.scs);\n    }\n\n    @Override\n    public void update(final UserMessage message) {\n        mod().setHudText(message);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/Log4jLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport net.pcal.fastback.common.utils.ProcessException;\n\nimport static java.util.Objects.requireNonNull;\n\npublic class Log4jLogger implements SystemLogger {\n\n    private final org.apache.logging.log4j.Logger log4j;\n    private boolean forceDebugEnabled = false;\n\n    public Log4jLogger(org.apache.logging.log4j.Logger log4j) {\n        this.log4j = requireNonNull(log4j);\n    }\n\n    @Override\n    public void setForceDebugEnabled(boolean forceDebugEnabled) {\n        this.forceDebugEnabled = forceDebugEnabled;\n    }\n\n    @Override\n    public void error(String message) {\n        this.log4j.error(message);\n    }\n\n    @Override\n    public void error(String message, Throwable t) {\n        // In the case of process execution failure, ensure we always dump the output along with the stacktrace so\n        // we have a prayer of understanding what actually went wrong\n        if (t instanceof ProcessException pe) pe.writeProcessOutput(this::error);\n        this.log4j.error(message, t);\n    }\n\n    @Override\n    public void warn(String message) {\n        this.log4j.warn(message);\n    }\n\n    @Override\n    public void info(String message) {\n        this.log4j.info(message);\n    }\n\n    @Override\n    public void debug(String message) {\n        if (this.forceDebugEnabled) {\n            this.log4j.info(\"[DEBUG] \" + message);\n        } else {\n            this.log4j.debug(message);\n        }\n    }\n\n    @Override\n    public void debug(String message, Throwable t) {\n        if (this.forceDebugEnabled) {\n            // In the case of process execution failure, ensure we always dump the output along with the stacktrace so\n            // we have a prayer of understanding what actually went wrong\n            if (t instanceof ProcessException pe) pe.writeProcessOutput(line -> this.log4j.info(\"[DEBUG] \" + message));\n            this.log4j.info(\"[DEBUG] \" + message, t);\n        } else {\n            this.log4j.debug(message, t);\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/ShutdownLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Handles messages in the context of the server shutting down.\n *\n * @author pcal\n * @since 0.15.0\n */\nenum ShutdownLogger implements UserLogger {\n\n    INSTANCE;\n\n    @Override\n    public void message(final UserMessage message) {\n        mod().setMessageScreenText(message);\n    }\n\n    @Override\n    public void update(final UserMessage message) {\n        mod().setHudText(message);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/SystemLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport java.util.function.Supplier;\n\n/**\n * Singleton logger instance that writes to the serverside console.\n *\n * @author pcal\n * @since 0.12.0\n */\npublic interface SystemLogger {\n\n    static SystemLogger syslog() {\n        return Singleton.INSTANCE;\n    }\n\n    void setForceDebugEnabled(boolean debug);\n\n    void error(String message);\n\n    void error(String message, Throwable t);\n\n    default void error(Throwable e) {\n        this.error(e.getMessage(), e);\n    }\n\n    void warn(String message);\n\n    void info(String message);\n\n    void debug(String message);\n\n    default void trace(Supplier<String> message) {\n        debug(message.get()); //FIXME\n    }\n\n    void debug(String message, Throwable t);\n\n    default void debug(Throwable t) {\n        this.debug(t.getMessage(), t);\n    }\n\n    class Singleton {\n        private static SystemLogger INSTANCE = null;\n\n        public static void register(SystemLogger logger) {\n            Singleton.INSTANCE = logger;\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/UserLogger.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\nimport com.mojang.brigadier.context.CommandContext;\nimport net.minecraft.commands.CommandSourceStack;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Logging interface for messages which *might* be displayed in the UI.\n *\n * @author pcal\n * @since 0.13.0\n */\npublic interface UserLogger extends AutoCloseable {\n\n    /**\n     * Send a fairly important message that should be displayed in the UI a relatively prominent and durable manner.\n     * Typically, this means in the chat dialog.\n     */\n    void message(UserMessage message);\n\n    /**\n     * Send a bit of low-level detail that is useful for indicating progress or activity but isn't of critical\n     * importance.  This will typically be displayed in the HUD area.\n     */\n    void update(UserMessage message);\n\n    @Override\n    default void close() {\n        mod().clearHudText();\n    }\n\n    default void internalError() {\n        this.message(styledLocalized(\"fastback.chat.internal-error\", ERROR));\n    }\n\n    default void internalError(Exception e) {\n        syslog().error(e);\n        internalError();\n    }\n\n    static UserLogger ulog(final CommandContext<CommandSourceStack> cc) {\n        return new CommandLogger(cc.getSource());\n    }\n\n    static UserLogger ulog(final CommandSourceStack scs) {\n        return new CommandLogger(scs);\n    }\n\n    static UserLogger forShutdown() {\n        return ShutdownLogger.INSTANCE;\n    }\n\n    static UserLogger forAutosave() {\n        return ShutdownLogger.INSTANCE;\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/logging/UserMessage.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.logging;\n\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL;\n\n/**\n * Abstract representation of a message to be displayed on the user's screen.  The message may or may\n * not be localizable.\n *\n * @author pcal\n */\npublic record UserMessage(LocalizedUserMessage localized, String raw, UserMessageStyle style) {\n\n    public enum UserMessageStyle {\n        NORMAL,\n        WARNING,\n        ERROR,\n        JGIT,\n        NATIVE_GIT,\n        BROADCAST,\n    }\n\n    public record LocalizedUserMessage(String key, Object... params) {\n\n        @Override\n        public String toString() {\n            return this.key + \" \" + (this.params != null ? params : \"[]\");\n        }\n    }\n\n    public static UserMessage localized(String key, Object... params) {\n        return styledLocalized(key, NORMAL, params);\n    }\n\n    public static UserMessage styledLocalized(String key, UserMessageStyle style, Object... params) {\n        return new UserMessage(new LocalizedUserMessage(key, params), null, style);\n    }\n\n    public static UserMessage raw(String text) {\n        return styledRaw(text, NORMAL);\n    }\n\n    public static UserMessage styledRaw(String text, UserMessageStyle style) {\n        return new UserMessage(null, requireNonNull(text), style);\n    }\n\n    @Override\n    public String toString() {\n        return this.raw != null ? raw : this.localized != null ? this.localized.toString() : null;\n    }\n\n    // ======================================================================\n    // Deprecated stuff\n\n    @Deprecated\n    public static UserMessage localizedError(String key, Object... params) {\n        return styledLocalized(key, ERROR, params);\n    }\n\n    @Deprecated\n    public static UserMessage rawError(String text) {\n        return styledRaw(text, ERROR);\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/FileFixerUpperMixin.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.util.filefix.FileFixerUpper;\nimport net.minecraft.util.filefix.virtualfilesystem.CopyOnWriteFileSystem;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.injection.At;\nimport org.spongepowered.asm.mixin.injection.Redirect;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.PathMatcher;\n\n/**\n * Prevents MC 26.1+'s CopyOnWriteFileSystem (used during world upgrade) from choking\n * on read-only git object files inside the .git directory.\n *\n * @author pcal\n * @since 0.31.2\n */\n@Mixin(FileFixerUpper.class)\npublic class FileFixerUpperMixin {\n\n    @Redirect(\n        method = \"applyFileFixersOnCow\",\n        at = @At(\n            value = \"INVOKE\",\n            target = \"Lnet/minecraft/util/filefix/virtualfilesystem/CopyOnWriteFileSystem;create(Ljava/lang/String;Ljava/nio/file/Path;Ljava/nio/file/Path;Ljava/nio/file/PathMatcher;)Lnet/minecraft/util/filefix/virtualfilesystem/CopyOnWriteFileSystem;\",\n            remap = false\n        ),\n        remap = false\n    )\n    private CopyOnWriteFileSystem fastback_skipGitDir(\n            String name, Path baseDir, Path tmpDir, PathMatcher original) throws IOException {\n        PathMatcher withGitSkip = path -> {\n            for (Path component : path) {\n                if (\".git\".equals(component.toString())) return true;\n            }\n            return original.matches(path);\n        };\n        return CopyOnWriteFileSystem.create(name, baseDir, tmpDir, withGitSkip);\n    }\n}\n\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/MessageScreenMixin.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.client.gui.GuiGraphicsExtractor;\nimport net.minecraft.client.gui.screens.GenericMessageScreen;\nimport net.pcal.fastback.common.mod.Mod;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.injection.At;\nimport org.spongepowered.asm.mixin.injection.Inject;\nimport org.spongepowered.asm.mixin.injection.callback.CallbackInfo;\n\n/**\n * Implements a callback that lets us render extra text on MessageScreens (i.e., exit/saving screen).\n *\n * @author pcal\n * @since 0.14.0\n */\n@Mixin(GenericMessageScreen.class)\npublic class MessageScreenMixin {\n\n    @Inject(method = \"extractBackground\", at = @At(\"TAIL\"), remap = false)\n    public void fastback_render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta, CallbackInfo ci) {\n        Mod.mod().renderMessageScreen(context);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/MinecraftServerMixin.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.server.MinecraftServer;\nimport net.pcal.fastback.common.mod.Mod;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.injection.At;\nimport org.spongepowered.asm.mixin.injection.Inject;\nimport org.spongepowered.asm.mixin.injection.Redirect;\nimport org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Allows us to disable vanilla saving during 'git add' to avoid coherency problems in the backup snapshots.  Also\n * sends notifications when autosaving completes so we can follow them with automated backups.\n *\n * @author pcal\n * @since 0.0.1\n */\n@Mixin(MinecraftServer.class)\npublic class MinecraftServerMixin {\n\n    /**\n     * Intercept the call to saveAll that triggers on autosave, pass it through and then send out notification that\n     * the autosave is done.\n     */\n    @Redirect(method = \"autoSave()V\",\n            at = @At(value = \"INVOKE\", target = \"Lnet/minecraft/server/MinecraftServer;saveEverything(ZZZ)Z\"), remap = false)\n    public boolean fastback_saveAll(MinecraftServer instance, boolean suppressLogs, boolean flush, boolean force) {\n        boolean result = instance.saveEverything(suppressLogs, flush, force);\n        Mod.mod().autoSaveCompleted();\n        return result;\n    }\n\n    /**\n     * Intercept save so we can hard-disable saving during critical parts of the backup.\n     */\n    @Inject(at = @At(\"HEAD\"), method = \"saveAllChunks(ZZZ)Z\", cancellable = true, remap = false)\n    public void fastback_save(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable<Boolean> ci) {\n        synchronized (this) {\n            if (Mod.mod().isWorldSaveEnabled()) {\n                syslog().debug(\"world saves are enabled, doing requested save\");\n            } else {\n                syslog().warn(\"Skipping requested save because a backup is in progress.\");\n                ci.setReturnValue(false);\n                ci.cancel();\n            }\n        }\n    }\n\n    /**\n     * Intercept saveAll so we can hard-disable saving during critical parts of the backup.\n     */\n    @Inject(at = @At(\"HEAD\"), method = \"saveEverything(ZZZ)Z\", cancellable = true, remap = false)\n    public void fastback_saveAll(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable<Boolean> ci) {\n        synchronized (this) {\n            if (Mod.mod().isWorldSaveEnabled()) {\n                syslog().debug(\"world saves are enabled, doing requested saveAll\");\n                //TODO should call save here to ensure all synced?\n            } else {\n                syslog().warn(\"Skipping requested saveAll because a backup is in progress.\");\n                ci.setReturnValue(false);\n                ci.cancel();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/ScreenAccessors.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.client.gui.screens.Screen;\nimport net.minecraft.network.chat.Component;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.Mutable;\nimport org.spongepowered.asm.mixin.gen.Accessor;\nimport org.spongepowered.asm.mixin.gen.Invoker;\n\n/**\n * @author pcal\n * @since 0.0.11\n */\n@Mixin(Screen.class)\npublic interface ScreenAccessors {\n\n    @Accessor(remap = false)\n    @Mutable\n    Component getTitle();\n\n    @Invoker(remap = false)\n    @Mutable\n    void invokeRebuildWidgets();\n\n    @Accessor(remap = false)\n    @Mutable\n    void setTitle(Component text);\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/ServerAccessors.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.server.MinecraftServer;\nimport net.minecraft.world.level.storage.LevelStorageSource;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.gen.Accessor;\n\n/**\n * @author pcal\n * @since 0.0.1\n */\n@Mixin(MinecraftServer.class)\npublic interface ServerAccessors {\n\n    @Accessor(remap = false)\n    int getTickCount();\n\n    @Accessor(remap = false)\n    LevelStorageSource.LevelStorageAccess getStorageSource();\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mixins/SessionAccessors.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mixins;\n\nimport net.minecraft.world.level.storage.LevelStorageSource;\nimport org.spongepowered.asm.mixin.Mixin;\nimport org.spongepowered.asm.mixin.gen.Accessor;\n\n/**\n * @author pcal\n * @since 0.0.1\n */\n@Mixin(LevelStorageSource.LevelStorageAccess.class)\npublic interface SessionAccessors {\n    @Accessor(remap = false)\n    LevelStorageSource.LevelDirectory getLevelDirectory();\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/AutosaveListener.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mod;\n\nimport net.pcal.fastback.common.commands.SchedulableAction;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.repo.RepoFactory;\nimport net.pcal.fastback.common.utils.Executor;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\n\nimport static net.pcal.fastback.common.commands.SchedulableAction.NONE;\nimport static net.pcal.fastback.common.commands.SchedulableAction.forConfigValue;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.utils.Executor.executor;\n\n/**\n * Responds to vanilla autosaves and follows them with an automatic backup (autoback).\n *\n * @author pcal\n * @since 0.2.0\n */\nclass AutosaveListener implements Runnable {\n\n    private long lastBackupTime = System.currentTimeMillis();\n\n    @Override\n    public void run() {\n        try (final UserLogger ulog = UserLogger.forAutosave()) {\n            executor().execute(Executor.ExecutionLock.WRITE, ulog, () -> {\n                try {\n                    final RepoFactory rf = RepoFactory.rf();\n                    final Path worldSaveDir = mod().getWorldDirectory();\n                    if (!rf.isGitRepo(worldSaveDir)) return;\n                    try (final Repo repo = rf.load(worldSaveDir)) {\n                        final GitConfig config = repo.getConfig();\n                        if (!config.getBoolean(IS_BACKUP_ENABLED)) return;\n                        final SchedulableAction autobackAction = forConfigValue(config, AUTOBACK_ACTION);\n                        if (autobackAction == null || autobackAction == NONE) return;\n                        final Duration waitTime = Duration.ofMinutes(config.getInt(AUTOBACK_WAIT_MINUTES));\n                        final Duration timeRemaining = waitTime.\n                                minus(Duration.ofMillis(System.currentTimeMillis() - lastBackupTime));\n                        if (!timeRemaining.isZero() && !timeRemaining.isNegative()) {\n                            syslog().debug(\"Skipping auto-backup until at least \" +\n                                    (timeRemaining.toSeconds() / 60) + \" more minutes have elapsed.\");\n                            return;\n                        }\n                        syslog().info(\"Starting auto-backup\");\n                        autobackAction.getTask(repo, ulog).call();\n                    }\n                    lastBackupTime = System.currentTimeMillis();\n                } catch (Exception e) {\n                    syslog().error(\"auto-backup failed.\", e);\n                }\n            });\n        }\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/ClientHelper.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mod;\n\nimport net.minecraft.client.Minecraft;\nimport net.minecraft.client.gui.GuiGraphicsExtractor;\nimport net.minecraft.client.gui.screens.GenericMessageScreen;\nimport net.minecraft.client.gui.screens.Screen;\nimport net.minecraft.network.chat.Component;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.mixins.ScreenAccessors;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.mod.UserMessageUtil.messageToText;\n\n/**\n * Client-only helper services. Holds vanilla Minecraft client state and provides\n * concrete implementations for HUD and message screen management.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic final class ClientHelper {\n\n    // ======================================================================\n    // Constants\n\n    private static final long TEXT_TIMEOUT = 10 * 1000;\n\n    // ======================================================================\n    // Fields\n\n    private final Minecraft client;\n    private Component hudText;\n    private long hudTextTime;\n\n    // ======================================================================\n    // Constructor\n\n    public ClientHelper(Minecraft client) {\n        this.client = client;\n    }\n\n    // ======================================================================\n    // Concrete — vanilla Minecraft implementations\n\n    public void setHudText(UserMessage userMessage) {\n        if (userMessage == null) {\n            clearHudText();\n        } else {\n            this.hudText = messageToText(userMessage);\n            this.hudTextTime = System.currentTimeMillis();\n        }\n    }\n\n    public void clearHudText() {\n        this.hudText = null;\n    }\n\n    public void setMessageScreenText(UserMessage userMessage) {\n        if (this.client == null) return;\n        final Screen screen = client.screen;\n        if (screen instanceof GenericMessageScreen) {\n            ((ScreenAccessors) screen).setTitle(messageToText(userMessage));\n            ((ScreenAccessors) screen).invokeRebuildWidgets(); // force it to rebuild the message component with the new title\n        }\n    }\n\n    public void renderMessageScreen(GuiGraphicsExtractor guiGraphics) {\n        renderHud(guiGraphics);\n    }\n\n    public void renderHud(GuiGraphicsExtractor guiGraphics) {\n        if (this.client == null) return;\n        if (this.hudText == null) return;\n        if (!this.client.options.showAutosaveIndicator().get()) return;\n        if (System.currentTimeMillis() - this.hudTextTime > TEXT_TIMEOUT) {\n            this.hudText = null;\n            syslog().debug(\"hud text timed out.  somebody forgot to clean up\");\n            return;\n        }\n        guiGraphics.text(this.client.font, this.hudText, 2, 2, 0xFFFFFF, false);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/LoaderHelper.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mod;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.commands.PermissionsFactory;\n\nimport java.nio.file.Path;\nimport java.util.Collection;\nimport java.util.Map;\nimport java.util.function.Function;\n\n/**\n * Abstracts away loader/environment-specific services that the mod framework\n * (e.g. Fabric) must provide.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic interface LoaderHelper {\n\n    /**\n     * @return the version string of the fastback mod as reported by the loader.\n     */\n    String getModVersion();\n\n    /**\n     * Appends loader-specific properties (e.g. the installed mod list) to the backup\n     * properties map. Common minecraft-* and fastback-version entries are added by ModImpl.\n     */\n    void addLoaderBackupProperties(Map<String, String> props);\n\n    /**\n     * @return path to the client 'saves' directory, or null on a dedicated server.\n     */\n    Path getSavesDir();\n\n    /**\n     * @return paths that should be included when mods-backup is enabled.\n     */\n    Collection<Path> getModsBackupPaths();\n\n    /**\n     * Create the /backup command using the given builder and register it.\n     *\n     * @param isClient true when running on an integrated (client-embedded) server.\n     */\n    void registerBackupCommand(boolean isClient,\n                               Function<PermissionsFactory<CommandSourceStack>, LiteralArgumentBuilder<CommandSourceStack>> builder);\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/Mod.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mod;\n\nimport net.minecraft.client.gui.GuiGraphicsExtractor;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.minecraft.server.MinecraftServer;\nimport net.pcal.fastback.common.logging.UserMessage;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.Collection;\nimport java.util.Map;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * Singleton that provides various mod-wide services.\n *\n * @author pcal\n * @since 0.1.0\n */\npublic interface Mod {\n\n    // ======================================================================\n    // Singleton\n\n    static Mod mod() {\n        return SingletonHolder.INSTANCE;\n    }\n\n    class SingletonHolder {\n        private static Mod INSTANCE = null;\n\n        public static void register(Mod mod) {\n            requireNonNull(mod);\n            if (INSTANCE != null) throw new IllegalStateException(\"Mod singleton initialized twice\");\n            SingletonHolder.INSTANCE = mod;\n        }\n    }\n\n    // ======================================================================\n    // Loader-facing methods\n\n    /**\n     * Initializes the mod for a dedicated server. Call once at startup.\n     */\n    static void initializeForDedicatedServer(LoaderHelper loaderHelper) {\n        ModImpl.initialize(loaderHelper, null);\n    }\n\n    /**\n     * Initializes the mod for a client (integrated or dedicated-server-from-client). Call once at startup.\n     */\n    static void initializeForClient(LoaderHelper loaderHelper, ClientHelper clientHelper) {\n        ModImpl.initialize(loaderHelper, clientHelper);\n    }\n\n    /**\n     * Must be called when a world is starting so that we can have a reference\n     * to the active world.\n     */\n    void onWorldStart(MinecraftServer server);\n\n    /**\n     * Must be called when a world is stopping to ensure we can run a\n     * shutdown backup.\n     */\n    void onWorldStop();\n\n    /**\n     * Allows loaders to plugin HUD rendering.\n     */\n    void renderHud(GuiGraphicsExtractor drawContext);\n\n    // ======================================================================\n    // Mixin-facing methods\n\n    /**\n     * Called from the mixins to check whether vanilla autosaving should\n     * be disabled.  Autosaving while a backup is in progress could result\n     * in an inconsistent backup state.\n     */\n    boolean isWorldSaveEnabled();\n\n    /**\n     * Called from the mixins to tell us that an autosave just finished.\n     * This may trigger a backup, depending on configuration.\n     */\n    void autoSaveCompleted();\n\n    /**\n     * Called from the shutdown message screen mixins to render additional text.\n     */\n    void renderMessageScreen(GuiGraphicsExtractor drawContext);\n\n\n    // ======================================================================\n    // Command-facing methods\n\n    /**\n     * @return path to where snapshots should be restored.\n     */\n    Path getDefaultRestoresDir() throws IOException;\n\n    /**\n     * @return the version of the fastback mod.\n     */\n    String getModVersion();\n\n    /**\n     * Enables or disables world saving.\n     */\n    void setWorldSaveEnabled(boolean enabled);\n\n    /**\n     * Forces a save of the world.  We often want to do this before doing a backup.\n     */\n    void saveWorld();\n\n    /**\n     * If we're clientside and the user is looking at a MessageScreen, set the title.\n     */\n    void setMessageScreenText(UserMessage message);\n\n    /**\n     * Send a chat message to user.\n     */\n    void sendChat(UserMessage message, CommandSourceStack scs);\n\n    /**\n     * If on a dedicated server, broadcast a message to the chat window of all connected users.\n     */\n    void sendBroadcast(UserMessage message);\n\n    /**\n     * Set magical floating text.  You MUST call clearHudText\n     */\n    void setHudText(UserMessage message);\n\n    /**\n     * Remove the magical floating text.\n     */\n    void clearHudText();\n\n    /**\n     * @return path to the save directory of the currently-loaded world (aka the git worktree).\n     */\n    Path getWorldDirectory();\n\n    /**\n     * @return name of the currently-loaded world.\n     */\n    String getWorldName();\n\n    /**\n     * @return paths to backup when mods-backup enabled.\n     */\n    Collection<Path> getModsBackupPaths();\n\n    /**\n     * Add extra properties that will be stored in .fastback/backup.properties.\n     */\n    void addBackupProperties(Map<String, String> props);\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/ModImpl.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.common.mod;\n\nimport net.minecraft.client.gui.GuiGraphicsExtractor;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.minecraft.server.MinecraftServer;\nimport net.minecraft.world.level.storage.LevelStorageSource;\nimport net.pcal.fastback.common.commands.Commands;\nimport net.pcal.fastback.common.commands.SchedulableAction;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.mixins.ServerAccessors;\nimport net.pcal.fastback.common.mixins.SessionAccessors;\nimport net.pcal.fastback.common.repo.Repo;\nimport net.pcal.fastback.common.repo.RepoFactory;\nimport org.apache.logging.log4j.LogManager;\nimport org.eclipse.jgit.transport.SshSessionFactory;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.Collection;\nimport java.util.Map;\n\nimport static java.nio.file.Files.createTempDirectory;\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.mod.UserMessageUtil.messageToText;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.getGitLfsVersion;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.getGitVersion;\nimport static net.pcal.fastback.common.utils.Executor.executor;\n\nclass ModImpl implements Mod {\n\n    // ======================================================================\n    // Fields\n\n    private final LoaderHelper loaderHelper;\n    private final ClientHelper clientHelper; // null on a dedicated server\n    private final Runnable autoSaveListener;\n    private MinecraftServer minecraftServer = null; // currently open world\n    private boolean isWorldSaveEnabled = true;\n    private Path tempRestoresDirectory = null;\n\n    // ======================================================================\n    // Factory — called by loader initializers\n\n    /**\n     * Creates, registers, and initializes a ModImpl.\n     *\n     * @param loaderHelper loader-specific services (always present)\n     * @param clientHelper client-specific services, or null on a dedicated server\n     */\n    static Mod initialize(final LoaderHelper loaderHelper, final ClientHelper clientHelper) {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"fastback\")));\n        final ModImpl mod = new ModImpl(loaderHelper, clientHelper);\n        SingletonHolder.register(mod);\n        mod.onInitialize();\n        return mod;\n    }\n\n    private ModImpl(LoaderHelper loaderHelper, ClientHelper clientHelper) {\n        this.loaderHelper = requireNonNull(loaderHelper);\n        this.clientHelper = clientHelper; // nullable — null means dedicated server\n        this.autoSaveListener = new AutosaveListener();\n    }\n\n    // ======================================================================\n    // Mod implementation\n\n    @Override\n    public void onWorldStart(final MinecraftServer minecraftServer) {\n        this.minecraftServer = requireNonNull(minecraftServer);\n        executor().start();\n        syslog().debug(\"onWorldStart complete\");\n    }\n\n    @Override\n    public void onWorldStop() {\n        try (final UserLogger ulog = UserLogger.forShutdown()) {\n            final Path worldSaveDir = this.getWorldDirectory();\n            if (executor().getActiveCount() > 0) {\n                this.setMessageScreenText(localized(\"fastback.chat.thread-waiting\"));\n            }\n            executor().stop();\n            this.clearHudText();\n            final RepoFactory rf = RepoFactory.rf();\n            if (rf.isGitRepo(worldSaveDir)) {\n                try (final Repo repo = rf.load(worldSaveDir)) {\n                    final GitConfig config = repo.getConfig();\n                    if (config.getBoolean(IS_BACKUP_ENABLED)) {\n                        final SchedulableAction action = SchedulableAction.forConfigValue(config, SHUTDOWN_ACTION);\n                        if (action != null) {\n                            this.setMessageScreenText(localized(\"fastback.message.backing-up\"));\n                            action.getTask(repo, ulog).call();\n                            this.setMessageScreenText(localized(\"fastback.chat.backup-complete\"));\n                        }\n                    }\n                } catch (Exception e) {\n                    syslog().error(\"Shutdown action failed.\", e);\n                }\n            }\n            syslog().debug(\"onWorldStop complete\");\n        }\n        this.minecraftServer = null;\n    }\n\n    @Override\n    public Path getDefaultRestoresDir() throws IOException {\n        Path savesDir = this.loaderHelper.getSavesDir();\n        if (savesDir != null) return savesDir;\n        if (tempRestoresDirectory == null) {\n            tempRestoresDirectory = createTempDirectory(\"fastback-restore\");\n        }\n        return tempRestoresDirectory;\n    }\n\n    @Override\n    public String getModVersion() {\n        return this.loaderHelper.getModVersion();\n    }\n\n    @Override\n    public void setWorldSaveEnabled(boolean enabled) {\n        this.isWorldSaveEnabled = enabled;\n    }\n\n    @Override\n    public void saveWorld() {\n        if (this.minecraftServer == null) throw new IllegalStateException();\n        this.minecraftServer.saveEverything(false, true, true);\n    }\n\n    @Override\n    public void sendChat(UserMessage message, CommandSourceStack scs) {\n        if (message.style() == ERROR) {\n            scs.sendFailure(messageToText(message));\n        } else {\n            scs.sendSuccess(() -> messageToText(message), false);\n        }\n    }\n\n    @Override\n    public void sendBroadcast(UserMessage userMessage) {\n        if (this.minecraftServer != null && this.minecraftServer.isDedicatedServer()) {\n            this.minecraftServer.getPlayerList().broadcastSystemMessage(messageToText(userMessage), false);\n        }\n    }\n\n    @Override\n    public void setHudText(UserMessage message) {\n        if (this.clientHelper == null) return;\n        if (message == null) {\n            syslog().debug(\"null unexpectedly passed to setHudText, ignoring\");\n            this.clearHudText();\n        } else {\n            this.clientHelper.setHudText(message);\n        }\n    }\n\n    @Override\n    public void clearHudText() {\n        if (this.clientHelper != null) this.clientHelper.clearHudText();\n    }\n\n    @Override\n    public void setMessageScreenText(UserMessage message) {\n        if (this.clientHelper != null)\n            this.clientHelper.setMessageScreenText(message);\n    }\n\n    @Override\n    public Path getWorldDirectory() {\n        if (this.minecraftServer == null) throw new IllegalStateException();\n        final LevelStorageSource.LevelStorageAccess session =\n                ((ServerAccessors) this.minecraftServer).getStorageSource();\n        return ((SessionAccessors) session).getLevelDirectory().path();\n    }\n\n    @Override\n    public String getWorldName() {\n        if (this.minecraftServer == null) throw new IllegalStateException();\n        return this.minecraftServer.getWorldData().getLevelName();\n    }\n\n    @Override\n    public void addBackupProperties(Map<String, String> props) {\n        props.put(\"fastback-version\", this.getModVersion());\n        if (this.minecraftServer != null) {\n            props.put(\"minecraft-version\", minecraftServer.getServerVersion());\n            props.put(\"minecraft-game-mode\", String.valueOf(minecraftServer.getWorldData().getGameType()));\n            props.put(\"minecraft-level-name\", minecraftServer.getWorldData().getLevelName());\n        }\n        this.loaderHelper.addLoaderBackupProperties(props);\n    }\n\n    @Override\n    public Collection<Path> getModsBackupPaths() {\n        return this.loaderHelper.getModsBackupPaths();\n    }\n\n    // ======================================================================\n    // Mod implementation (continued)\n\n    @Override\n    public boolean isWorldSaveEnabled() {\n        return this.isWorldSaveEnabled;\n    }\n\n    @Override\n    public void autoSaveCompleted() {\n        if (this.autoSaveListener != null) {\n            this.autoSaveListener.run();\n        } else {\n            syslog().warn(\"Autosave just happened but, unexpectedly, no one is listening.\");\n        }\n    }\n\n    @Override\n    public void renderMessageScreen(GuiGraphicsExtractor drawContext) {\n        if (this.clientHelper != null) {\n            this.clientHelper.renderMessageScreen(drawContext);\n        } else {\n            syslog().warn(\"renderMessageScreen called when clientHelper not set.\");\n        }\n    }\n\n    @Override\n    public void renderHud(GuiGraphicsExtractor drawContext) {\n        if (this.clientHelper != null) {\n            this.clientHelper.renderHud(drawContext);\n        } else {\n            syslog().warn(\"renderHud called when clientHelper not set.\");\n        }\n    }\n\n    // ======================================================================\n    // Private methods\n\n    private void onInitialize() {\n        final String gitVersion = getGitVersion();\n        if (gitVersion == null) {\n            syslog().warn(\"git is not installed.\");\n        } else {\n            syslog().info(\"git is installed: \" + gitVersion);\n        }\n        final String gitLfsVersion = getGitLfsVersion();\n        if (gitLfsVersion == null) {\n            syslog().warn(\"git-lfs is not installed.\");\n        } else {\n            syslog().info(\"git-lfs is installed: \" + gitLfsVersion);\n        }\n        if (SshSessionFactory.getInstance() == null) {\n            syslog().warn(\"An ssh provider was not initialized for jgit.  Operations on a remote repo over ssh will fail.\");\n        } else {\n            syslog().info(\"SshSessionFactory: \" + SshSessionFactory.getInstance());\n        }\n        this.loaderHelper.registerBackupCommand(clientHelper != null, Commands::createBackupCommand);\n        syslog().debug(\"onInitialize complete\");\n    }\n}"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/mod/UserMessageUtil.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.mod;\n\nimport net.minecraft.network.chat.Component;\nimport net.minecraft.network.chat.MutableComponent;\nimport net.minecraft.network.chat.TextColor;\nimport net.pcal.fastback.common.logging.UserMessage;\n\nimport static net.minecraft.ChatFormatting.GRAY;\nimport static net.minecraft.ChatFormatting.GREEN;\nimport static net.minecraft.ChatFormatting.RED;\nimport static net.minecraft.ChatFormatting.YELLOW;\nimport static net.minecraft.network.chat.Style.EMPTY;\n\n/**\n * Utility for converting {@link UserMessage} to Minecraft {@link Component}.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic class UserMessageUtil {\n\n    public static Component messageToText(final UserMessage m) {\n        final MutableComponent out;\n        if (m.localized() != null) {\n            out = Component.translatable(\n                m.localized().key(),\n                messageParamsToComponentArgs(m.localized().params())\n            );\n        } else {\n            out = Component.literal(m.raw());\n        }\n        switch (m.style()) {\n            case ERROR -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(RED)));\n            case WARNING -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(YELLOW)));\n            case JGIT -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(GRAY)));\n            case NATIVE_GIT -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(GREEN)));\n        }\n        return out;\n    }\n\n    private static Object[] messageParamsToComponentArgs(final Object[] params) {\n        if (params == null) return new Object[0];\n\n        final Object[] out = new Object[params.length];\n        for (int i = 0; i < params.length; i++) {\n            final Object param = params[i];\n            if (param instanceof Component) {\n                out[i] = param;\n            } else {\n                out[i] = String.valueOf(param);\n            }\n        }\n        return out;\n    }\n\n    private UserMessageUtil() {}\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/BranchUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec;\nimport org.eclipse.jgit.api.errors.GitAPIException;\n\nimport java.io.IOException;\nimport java.text.ParseException;\nimport java.util.Collection;\nimport java.util.HashSet;\nimport java.util.Set;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n\n/**\n * @author pcal\n */\nabstract class BranchUtils {\n\n    /**\n     * Get the snapshots for this repo.  Snapshot branches for worlds other than the Repo's are ignored.\n     */\n    static Set<SnapshotId> listSnapshots(RepoImpl repo, JGitSupplier<Collection<String>> refProvider) throws GitAPIException, IOException {\n        final Collection<String> refs = refProvider.get();\n        final SnapshotIdCodec codec = repo.getSidCodec();\n        final Set<SnapshotId> out = new HashSet<>();\n        for (final String ref : refs) {\n            String branchName = getBranchName(ref);\n            if (repo.getSidCodec().isSnapshotBranchName(repo.getWorldId(), branchName)) {\n                final SnapshotId sid;\n                try {\n                    sid = requireNonNull(codec.fromBranch(branchName));\n                } catch (ParseException pe) {\n                    syslog().error(\"Unexpected parse error, ignoring branch \" + branchName, pe);\n                    continue;\n                }\n                if (sid.getWorldId().equals(repo.getWorldId())) {\n                    out.add(sid);\n                } else {\n                    syslog().debug(\"Ignoring branch from other world \" + branchName);\n                }\n            } else {\n                syslog().debug(\"Ignoring unrecognized branch \" + branchName);\n            }\n        }\n        return out;\n    }\n\n    static String getBranchName(String name) {\n        final String REFS_HEADS = \"refs/heads/\";\n        if (name.startsWith(REFS_HEADS)) {\n            return name.substring(REFS_HEADS.length());\n        } else {\n            return null;\n        }\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/CommitUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.utils.EnvironmentUtils;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport org.apache.commons.io.FileUtils;\nimport org.eclipse.jgit.api.AddCommand;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.ResetCommand;\nimport org.eclipse.jgit.api.RmCommand;\nimport org.eclipse.jgit.api.Status;\nimport org.eclipse.jgit.api.errors.GitAPIException;\n\nimport java.io.File;\nimport java.io.FileWriter;\nimport java.io.IOException;\nimport java.io.PrintWriter;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.RepoImpl.FASTBACK_DIR;\nimport static net.pcal.fastback.common.utils.ProcessUtils.doExec;\n\n/**\n * Utilities for adding and committing snapshots.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class CommitUtils {\n\n    static SnapshotId doCommitSnapshot(final RepoImpl repo, final UserLogger ulog) throws IOException, ProcessException, GitAPIException {\n        PreflightUtils.doPreflight(repo);\n        final WorldId uuid = repo.getWorldId();\n        final GitConfig conf = repo.getConfig();\n        final SnapshotId newSid = repo.getSidCodec().create(uuid);\n        syslog().debug(\"start doCommitSnapshot for \" + newSid);\n        writeBackupProperties(repo);\n\n        if (conf.getBoolean(IS_MODS_BACKUP_ENABLED)) {\n            doSettingsBackup(repo, ulog);\n        }\n\n        final String newBranchName = newSid.getBranchName();\n        try {\n            if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                ulog.message(styledLocalized(\"fastback.chat.commit-start\", NATIVE_GIT, newSid.getShortName()));\n                native_commit(newBranchName, repo, ulog);\n            } else {\n                ulog.message(styledLocalized(\"fastback.chat.commit-start\", NORMAL, newSid.getShortName()));\n                jgit_commit(newBranchName, repo.getJGit(), ulog);\n            }\n        } catch (GitAPIException | InterruptedException e) {\n            throw new IOException(e);\n        }\n        syslog().debug(\"Local backup complete.\");\n        return newSid;\n    }\n\n    private static void doSettingsBackup(RepoImpl repo, UserLogger ulog) {\n        syslog().info(\"Backing up minecraft settings\");\n        try {\n            final File backupDir = repo.getDotFasbackDir().resolve(\"mods-backup\").toFile();\n            if (backupDir.exists()) FileUtils.deleteDirectory(backupDir);\n            backupDir.mkdirs();\n            for (Path src : mod().getModsBackupPaths()) {\n                try {\n                    final File srcFile = src.toFile();\n                    syslog().debug(\"backing up \" + srcFile + \" to \" + backupDir);\n                    if (srcFile.exists()) {\n                        if (srcFile.isDirectory()) {\n                            FileUtils.copyDirectory(srcFile,\n                                    backupDir.toPath().resolve(srcFile.getName()).toFile());\n                        } else {\n                            FileUtils.copyFile(srcFile, backupDir);\n                        }\n                    }\n                } catch (Exception ohwell) {\n                    syslog().error(ohwell);\n                }\n            }\n        } catch (Exception ohwell) {\n            syslog().error(ohwell);\n        }\n    }\n\n    private static void native_commit(final String newBranchName, final Repo repo, final UserLogger ulog) throws IOException, InterruptedException {\n        syslog().debug(\"Start native_commit\");\n        ulog.update(styledLocalized(\"fastback.hud.local-saving\", NATIVE_GIT));\n        final File worktree = repo.getWorkTree();\n        final Map<String, String> env = Map.of(\"GIT_LFS_FORCE_PROGRESS\", \"1\");\n        final Consumer<String> outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT));\n        String[] checkout = {\"git\", \"-C\", worktree.getAbsolutePath(), \"checkout\", \"--orphan\", newBranchName};\n        try {\n            doExec(checkout, env, outputConsumer, outputConsumer);\n            mod().setWorldSaveEnabled(false);\n            try {\n                String[] add = {\"git\", \"-C\", worktree.getAbsolutePath(), \"add\", \"-v\", \".\"};\n                doExec(add, env, outputConsumer, outputConsumer);\n            } finally {\n                mod().setWorldSaveEnabled(true);\n                syslog().debug(\"World save re-enabled.\");\n            }\n            {\n                String[] commit = {\"git\", \"-C\", worktree.getAbsolutePath(), \"commit\", \"-m\", newBranchName};\n                doExec(commit, env, outputConsumer, outputConsumer);\n            }\n        } catch (ProcessException e) {\n            syslog().error(e);\n            ulog.message(styledLocalized(\"fastback.chat.commit-failed\", ERROR));\n            return;\n        }\n        syslog().debug(\"End native_commit\");\n    }\n\n    private static void jgit_commit(final String newBranchName, final Git jgit, final UserLogger ulog) throws GitAPIException, IOException {\n        syslog().debug(\"Starting jgit_commit\");\n        ulog.update(styledLocalized(\"fastback.hud.local-saving\", JGIT));\n        jgit.checkout().setOrphan(true).setName(newBranchName).call();\n        jgit.reset().setMode(ResetCommand.ResetType.SOFT).call();\n        syslog().debug(\"status\");\n        final Status status = jgit.status().call();\n\n        try {\n\n            syslog().debug(\"Disabling world save for 'git add'\");\n            mod().setWorldSaveEnabled(false);\n\n\n            //\n            // Figure out what files to add and remove.  We don't just 'git add .' because this:\n            // https://bugs.eclipse.org/bugs/show_bug.cgi?id=494323\n            //\n            {\n                final List<String> toAdd = new ArrayList<>();\n                toAdd.add(FASTBACK_DIR);\n                toAdd.addAll(status.getModified());\n                toAdd.addAll(status.getUntracked());\n                Collections.sort(toAdd);\n                if (!toAdd.isEmpty()) {\n                    syslog().debug(\"Adding \" + toAdd.size() + \" new or modified files to index\");\n\n                    for (final String file : toAdd) {\n                        final AddCommand gitAdd = jgit.add();\n                        syslog().debug(\"add  \" + file);\n                        ulog.update(styledLocalized(\"fastback.chat.backup-start\", JGIT, file));\n                        gitAdd.addFilepattern(file);\n                        gitAdd.call();\n                    }\n                }\n            }\n            {\n                final List<String> toDelete = new ArrayList<>();\n                toDelete.addAll(status.getRemoved());\n                toDelete.addAll(status.getMissing());\n                Collections.sort(toDelete);\n                if (!toDelete.isEmpty()) {\n                    syslog().debug(\"Removing \" + toDelete.size() + \" deleted files from index\");\n                    for (final String file : toDelete) {\n                        final RmCommand gitRm = jgit.rm();\n                        syslog().debug(\"rm  \" + file);\n                        ulog.update(styledLocalized(\"fastback.chat.backup-start\", JGIT, file));\n                        gitRm.addFilepattern(file);\n                        gitRm.call();\n                    }\n                }\n            }\n        } finally {\n            mod().setWorldSaveEnabled(true);\n            syslog().debug(\"World save re-enabled.\");\n        }\n        syslog().debug(\"commit\");\n        ulog.update(styledLocalized(\"fastback.chat.commit-complete\", JGIT));\n        jgit.commit().setMessage(newBranchName).call();\n    }\n\n    private static void writeBackupProperties(Repo repo) throws IOException {\n        final Map<String, String> props = new HashMap<>();\n        GitConfig conf = repo.getConfig();\n        props.put(\"fastback-\" + IS_NATIVE_GIT_ENABLED.getSettingName(), conf.getString(IS_NATIVE_GIT_ENABLED));\n        props.put(\"git-version\", EnvironmentUtils.getGitVersion());\n        props.put(\"git-lfs-version\", EnvironmentUtils.getGitLfsVersion());\n        try {\n            mod().addBackupProperties(props);\n        } catch (Exception e) {\n            syslog().error(\"Failed to add extra backup.properties\", e);\n        }\n        final Path path = repo.getWorkTree().toPath().resolve(FASTBACK_DIR + \"/backup.properties\");\n        final List<String> keys = new ArrayList<>(props.keySet());\n        try (final PrintWriter pw = new PrintWriter(new FileWriter(path.toFile()))) {\n            Collections.sort(keys);\n            for (String key : keys) {\n                pw.println(key + \" = \" + props.get(key));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/JGitConsumer.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport java.io.IOException;\n\n/**\n * @author pcal\n */\n@FunctionalInterface\ninterface JGitConsumer<T> {\n\n    void accept(T t) throws IOException;\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/JGitFunction.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport org.eclipse.jgit.api.errors.GitAPIException;\n\nimport java.io.IOException;\n\n/**\n * Function with typed exceptions for typical JGit operations.\n *\n * @author pcal\n */\n@FunctionalInterface\ninterface JGitFunction<T, R> {\n\n    R apply(T arg) throws IOException, GitAPIException;\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/JGitIncrementalProgressMonitor.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport org.eclipse.jgit.lib.ProgressMonitor;\n\nimport static java.util.Objects.requireNonNull;\n\nclass JGitIncrementalProgressMonitor implements ProgressMonitor {\n\n    private final ProgressMonitor delegate;\n    private final int totalIncrements;\n    private int workComplete;\n    private int totalWork;\n    private int workCompletedInIncrement;\n    private int workCompleteScaled;\n\n    public JGitIncrementalProgressMonitor(ProgressMonitor delegate, int totalIncrements) {\n        this.delegate = requireNonNull(delegate);\n        this.totalIncrements = totalIncrements;\n    }\n\n    @Override\n    public void start(int totalTasks) {\n        this.delegate.start(totalTasks);\n    }\n\n    @Override\n    public void beginTask(String taskName, int totalWork) {\n        this.delegate.beginTask(taskName, totalWork);\n        this.totalWork = totalWork;\n        this.workComplete = 0;\n        this.workCompleteScaled = 0;\n        this.workCompletedInIncrement = 0;\n    }\n\n    @Override\n    public void update(int completed) {\n        this.workComplete += completed;\n        this.workCompletedInIncrement += completed;\n        if (this.totalWork == 0) return;\n        int newWorkCompleteScaled = (this.workComplete * totalIncrements) / this.totalWork;\n        if (newWorkCompleteScaled > this.workCompleteScaled) {\n            this.workCompleteScaled = newWorkCompleteScaled;\n            this.delegate.update(workCompletedInIncrement);\n            this.workCompletedInIncrement = 0;\n        }\n    }\n\n    @Override\n    public void endTask() {\n        this.delegate.endTask();\n    }\n\n    @Override\n    public boolean isCancelled() {\n        return false;\n    }\n\n    @Override\n    public void showDuration(boolean enabled) {\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/JGitPercentageProgressMonitor.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport org.eclipse.jgit.lib.ProgressMonitor;\n\nabstract class JGitPercentageProgressMonitor implements ProgressMonitor {\n\n    private String currentTask;\n    private int currentTotalWork;\n    private int totalCompleted;\n\n    protected JGitPercentageProgressMonitor() {\n    }\n\n    @Override\n    final public void start(int totalTasks) {\n    }\n\n    @Override\n    final public void beginTask(String taskName, int totalWork) {\n        this.currentTask = taskName;\n        this.currentTotalWork = totalWork;\n        this.totalCompleted = 0;\n        this.progressStart(currentTask);\n    }\n\n    @Override\n    final public void update(int completed) {\n        this.totalCompleted += completed;\n        int percent = this.currentTotalWork == 0 ? 0 : (this.totalCompleted * 100) / this.currentTotalWork;\n        this.progressUpdate(currentTask, percent);\n    }\n\n    @Override\n    final public void endTask() {\n        this.progressDone(currentTask);\n        currentTask = null;\n    }\n\n    @Override\n    final public boolean isCancelled() {\n        return false;\n    }\n\n    protected abstract void progressStart(String taskName);\n\n    protected abstract void progressUpdate(String taskName, int percentage);\n\n    protected abstract void progressDone(String taskName);\n\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/JGitSupplier.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport java.io.IOException;\n\n/**\n * Supplier with typed exceptions for typical JGit operations.\n */\n@FunctionalInterface\ninterface JGitSupplier<R> {\n\n    R get() throws IOException;\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/PreflightUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.lib.StoredConfig;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.Collections;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.UPDATE_GITATTRIBUTES_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.UPDATE_GITIGNORE_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.utils.FileUtils.writeResourceToFile;\nimport static net.pcal.fastback.common.utils.ProcessUtils.doExec;\n\n/**\n * Utilities for keeping the repo configuration up-to-date.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class PreflightUtils {\n\n    // ======================================================================\n    // Util methods\n\n    /**\n     * Should be called prior to any heavy-lifting with git (e.g. committing or pushing).  Ensures that\n     * key settings are all set correctly.\n     */\n    static void doPreflight(RepoImpl repo) throws IOException, ProcessException, GitAPIException {\n        final SystemLogger syslog = syslog();\n        syslog.debug(\"Doing world maintenance\");\n        final Git jgit = repo.getJGit();\n        final Path worldSaveDir = jgit.getRepository().getWorkTree().toPath();\n        WorldIdUtils.ensureWorldHasId(worldSaveDir);\n        final GitConfig config = GitConfig.load(jgit);\n        if (config.getBoolean(UPDATE_GITIGNORE_ENABLED)) {\n            final Path targetPath = worldSaveDir.resolve(\".gitignore\");\n            writeResourceToFile(\"world/gitignore\", targetPath);\n        }\n        if (config.getBoolean(UPDATE_GITATTRIBUTES_ENABLED)) {\n            final Path targetPath = worldSaveDir.resolve(\".gitattributes\");\n            if (config.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                writeResourceToFile(\"world/gitattributes-native\", targetPath);\n            } else {\n                writeResourceToFile(\"world/gitattributes-jgit\", targetPath);\n            }\n        }\n        updateNativeLfsInstallation(repo);\n    }\n\n    // ======================================================================\n    // Private\n\n    /**\n     * Ensures that git-lfs is installed or uninstalled in the worktree as appropriate.\n     */\n    private static void updateNativeLfsInstallation(final RepoImpl repo) throws ProcessException, GitAPIException {\n        if (repo.getConfig().getBoolean(IS_NATIVE_GIT_ENABLED)) {\n            final String[] cmd = {\"git\", \"-C\", repo.getWorkTree().getAbsolutePath(), \"lfs\", \"install\", \"--local\"};\n            doExec(cmd, Collections.emptyMap(), s -> {}, s -> {});\n        } else {\n            try {\n                // jgit has builtin support for lfs, but it's weird not compatible with native lfs, so lets just\n                // try to avoid letting them use it.\n                StoredConfig jgitConfig = repo.getJGit().getRepository().getConfig();\n                jgitConfig.unsetSection(\"lfs\", null);\n                jgitConfig.unsetSection(\"filter\", \"lfs\");\n                jgitConfig.save();\n            } catch (Exception ohwell) {\n                syslog().debug(ohwell);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/PruneUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.FastbackConfigKey;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.retention.RetentionPolicy;\nimport net.pcal.fastback.common.retention.RetentionPolicyCodec;\nimport net.pcal.fastback.common.retention.RetentionPolicyType;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport net.pcal.fastback.common.utils.ProcessUtils;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.transport.RefSpec;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Set;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static org.apache.commons.lang3.function.Consumers.nop;\n\n/**\n * Utils for pruning and deleting snapshot branches.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class PruneUtils {\n\n    static void deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws IOException {\n        GitConfig config = repo.getConfig();\n        try {\n            if (config.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                native_deleteRemoteBranch(repo, remoteBranchName);\n            } else {\n                jgit_deleteRemoteBranch(repo, remoteBranchName);\n            }\n        } catch (GitAPIException | ProcessException e) {\n            throw new IOException(e);\n        }\n    }\n\n    static void native_deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws ProcessException {\n        String[] command = {\"git\", \"-C\", repo.getWorkTree().getAbsolutePath(), \"push\", repo.getConfig().getString(REMOTE_NAME), \"--delete\", remoteBranchName};\n        ProcessUtils.doExec(command, Collections.emptyMap(), nop(), nop(), true);\n    }\n\n    static void jgit_deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws GitAPIException {\n        RefSpec refSpec = new RefSpec()\n                .setSource(null)\n                .setDestination(\"refs/heads/\" + remoteBranchName);\n        repo.getJGit().push().setRefSpecs(refSpec).setRemote(remoteBranchName).call();\n    }\n\n    static void deleteLocalBranches(final RepoImpl repo, List<String> branchNames) throws IOException {\n        try {\n            repo.getJGit().branchDelete().setForce(true).setBranchNames(branchNames.toArray(new String[0])).call();\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n    }\n\n    static Collection<SnapshotId> doLocalPrune(final RepoImpl repo, final UserLogger log) throws IOException {\n        return doPrune(repo, log,\n                LOCAL_RETENTION_POLICY,\n                repo::getLocalSnapshots,\n                sid -> {\n                    syslog().info(\"Pruning local snapshot \" + sid.getBranchName());\n                    deleteLocalBranches(repo, List.of(sid.getBranchName()));\n                },\n                \"fastback.chat.retention-policy-not-set\"\n        );\n    }\n\n    static Collection<SnapshotId> doRemotePrune(RepoImpl repo, UserLogger ulog) throws IOException {\n        return doPrune(repo, ulog,\n                FastbackConfigKey.REMOTE_RETENTION_POLICY,\n                repo::getRemoteSnapshots,\n                sid -> {\n                    syslog().info(\"Pruning remote snapshot \" + sid.getBranchName());\n                    repo.deleteRemoteBranch(sid.getBranchName());\n                },\n                \"fastback.chat.remote-retention-policy-not-set\"\n        );\n    }\n\n    private static Collection<SnapshotId> doPrune(Repo repo,\n                                                  UserLogger log,\n                                                  FastbackConfigKey policyConfigKey,\n                                                  JGitSupplier<Set<SnapshotId>> listSnapshotsFn,\n                                                  JGitConsumer<SnapshotId> deleteSnapshotsFn,\n                                                  String notSetKey) throws IOException {\n        final GitConfig conf = repo.getConfig();\n        RetentionPolicy policy = null;\n        final String policyConfig = conf.getString(policyConfigKey);\n        if (policyConfig != null) {\n            policy = RetentionPolicyCodec.INSTANCE.decodePolicy(RetentionPolicyType.getAvailable(), policyConfig);\n        }\n        if (policy == null) {\n            log.message(styledLocalized(notSetKey, ERROR));\n            return null;\n        }\n        final Collection<SnapshotId> toPruneUnsorted = policy.getSnapshotsToPrune(listSnapshotsFn.get());\n        final List<SnapshotId> toPrune = new ArrayList<>(toPruneUnsorted);\n        Collections.sort(toPrune);\n        log.update(UserMessage.localized(\"fastback.hud.prune-started\"));\n        for (final SnapshotId sid : toPrune) {\n            deleteSnapshotsFn.accept(sid);\n        }\n        return toPrune;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/PushUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport com.google.common.collect.ListMultimap;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport net.pcal.fastback.common.utils.ProcessUtils;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.lib.ObjectId;\nimport org.eclipse.jgit.lib.ProgressMonitor;\nimport org.eclipse.jgit.lib.Ref;\nimport org.eclipse.jgit.merge.ContentMergeStrategy;\nimport org.eclipse.jgit.transport.PushResult;\nimport org.eclipse.jgit.transport.RefSpec;\nimport org.eclipse.jgit.transport.RemoteConfig;\nimport org.eclipse.jgit.transport.TrackingRefUpdate;\nimport org.eclipse.jgit.transport.URIish;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.function.Consumer;\n\nimport static java.util.Arrays.asList;\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_SMART_PUSH_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_TEMP_BRANCH_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_TRACKING_BRANCH_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_UUID_CHECK_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.utils.ProcessUtils.doExec;\n\n/**\n * Utils for pushing changes to a remote.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class PushUtils {\n\n    static boolean isTempBranch(String branchName) {\n        return branchName.startsWith(\"temp/\");\n    }\n\n    // TODO stop throwing IOE\n    // TODO stop passing repo\n    static void doPush(SnapshotId sid, RepoImpl repo, UserLogger ulog) throws IOException, ProcessException {\n        try {\n            final GitConfig conf = repo.getConfig();\n            final String pushUrl = conf.getString(REMOTE_PUSH_URL);\n            if (pushUrl == null) {\n                syslog().warn(\"Skipping remote backup because no remote url has been configured.\");\n                return;\n            }\n            final Git jgit = repo.getJGit();\n            final Collection<String> remoteBranchRefs;\n            final String remoteName = conf.getString(REMOTE_NAME);\n            if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                remoteBranchRefs = native_lsRemote(repo.getWorkTree().toPath(), remoteName);\n            } else {\n                remoteBranchRefs = jgit_lsRemote(repo.getJGit(), remoteName);\n            }\n            final ListMultimap<WorldId, SnapshotId> snapshotsPerWorld =\n                    SnapshotIdUtils.getSnapshotsPerWorld(remoteBranchRefs, repo.getSidCodec());\n            if (conf.getBoolean(IS_UUID_CHECK_ENABLED)) {\n                boolean uuidCheckResult;\n                try {\n                    uuidCheckResult = doWorldIdCheck(repo, snapshotsPerWorld.keySet());\n                } catch (final IOException e) {\n                    syslog().error(\"Unexpected exception thrown during id check\", e);\n                    uuidCheckResult = false;\n                }\n                if (!uuidCheckResult) {\n                    final URIish remoteUri = getRemoteUri(repo.getJGit(), repo.getConfig().getString(REMOTE_NAME));\n                    ulog.message(styledLocalized(\"fastback.chat.push-id-mismatch\", ERROR, remoteUri));\n                    syslog().error(\"Failing remote backup due to failed id check\");\n                    throw new IOException();\n                }\n            }\n            syslog().debug(\"Pushing to \" + pushUrl);\n            PreflightUtils.doPreflight(repo);\n            if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                ulog.message(styledLocalized(\"fastback.chat.push-started\", NATIVE_GIT, pushUrl));\n                native_doPush(repo, sid.getBranchName(), ulog);\n            } else if (conf.getBoolean(IS_SMART_PUSH_ENABLED)) {\n                ulog.message(styledLocalized(\"fastback.chat.push-started\", NORMAL, pushUrl));\n                final WorldId uuid = repo.getWorldId();\n                jgit_doSmartPush(repo, snapshotsPerWorld.get(uuid), sid.getBranchName(), conf, ulog);\n            } else {\n                ulog.message(styledLocalized(\"fastback.chat.push-started\", NORMAL, pushUrl));\n                jgit_doPush(jgit, sid.getBranchName(), conf, ulog);\n            }\n            syslog().info(\"Remote backup complete.\");\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n    }\n\n    // TODO stop passing repo\n    private static void native_doPush(final Repo repo, final String branchNameToPush, final UserLogger log) throws ProcessException {\n        syslog().debug(\"Start native_push\");\n        final File worktree = repo.getWorkTree();\n        final GitConfig conf = repo.getConfig();\n        String remoteName = conf.getString(REMOTE_NAME);\n        final String[] push = {\"git\", \"-C\", worktree.getAbsolutePath(), \"-c\", \"push.autosetupremote=false\", \"push\", \"--progress\", \"--set-upstream\", remoteName, branchNameToPush};\n        final Map<String, String> env = Map.of(\"GIT_LFS_FORCE_PROGRESS\", \"1\");\n        final Consumer<String> outputConsumer = line -> log.update(styledRaw(line, NATIVE_GIT));\n        doExec(push, env, outputConsumer, outputConsumer);\n        syslog().debug(\"End native_push\");\n    }\n\n    private static void jgit_doPush(final Git jgit, final String branchNameToPush, final GitConfig conf, final UserLogger ulog) throws GitAPIException {\n        final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitPushProgressMonitor(ulog), 100);\n        final String remoteName = conf.getString(REMOTE_NAME);\n        syslog().info(\"Doing simple push of \" + branchNameToPush);\n        jgit.push().setProgressMonitor(pm).setRemote(remoteName).\n                setRefSpecs(new RefSpec(branchNameToPush + \":\" + branchNameToPush)).call();\n    }\n\n    static Collection<String> native_lsRemote(final Path worktree, final String remoteName) throws ProcessException {\n        final List<String> command = new ArrayList<>(asList(\"git\", \"-C\",\n                worktree.toAbsolutePath().toString(), \"ls-remote\",  \"--heads\", remoteName));\n        final List<String> result = new ArrayList<>();\n        ProcessUtils.doExec(\n                command.toArray(String[]::new),\n                Map.of(),\n                line -> {\n                    if (!line.isEmpty()) {\n                        String refName = line.split(\"\\t\")[1];\n                        result.add(refName);\n                    }\n                },\n                unused -> {},\n                false\n        );\n        return result;\n    }\n\n    static Collection<String> jgit_lsRemote(final Git jgit, final String remoteName) throws GitAPIException {\n        return jgit.lsRemote().setHeads(true).setTags(false).setRemote(remoteName).call()\n                .stream().map(Ref::getName).toList();\n    }\n\n    /**\n     * This is a probably-failed attempt at an optimization.  It is no longer the default behavior.\n     * <p>\n     * The idea was to try to minimize re-pushing unchanged blobs by establishing a common merge history between the\n     * branch being pushed and one we already know is upstream Again, all snapshot branches are orphan branches, and\n     * git can't bother checking every single blob in unrelated branches when receiving a push.\n     * <p>\n     * But it does do some of this when there is a related history, so the idea here is to create a temporary merge\n     * commit between a branch the server has and the new one it doesn't have, and to then let it figure out that most\n     * of the blobs in those commits are already present on the server.   Unfortunately, I think I may misunderstand\n     * how git behaves here - I think the deduplication is only at commit granularity, so this actually isn't doing\n     * anything except adding a lot of moving parts and problems like this one:\n     * <p>\n     * https://github.com/pcal43/fastback/issues/267\n     * <p>\n     * So, this is no longer enabled by default.  And really, if they're backing up a big world where this matters,\n     * you should just be using native git.\n     */\n    private static void jgit_doSmartPush(final RepoImpl repo, List<SnapshotId> remoteSnapshots, final String branchNameToPush, final GitConfig conf, final UserLogger ulog) throws IOException {\n        ulog.update(styledLocalized(\"fastback.chat.push-started\", JGIT));\n        try {\n            final Git jgit = repo.getJGit();\n            final String remoteName = conf.getString(REMOTE_NAME);\n            final WorldId worldUuid = repo.getWorldId();\n            final SnapshotId latestCommonSnapshot;\n            if (remoteSnapshots.isEmpty()) {\n                syslog().warn(\"** This appears to be the first time this world has been pushed.\");\n                syslog().warn(\"** If the world is large, this may take some time.\");\n                jgit_doPush(jgit, branchNameToPush, conf, ulog);\n                return;\n            } else {\n                final Collection<Ref> localBranchRefs = jgit.branchList().call();\n                final ListMultimap<WorldId, SnapshotId> localSnapshotsPerWorld =\n                        SnapshotIdUtils.getSnapshotsPerWorld(localBranchRefs.stream().map(Ref::getName).toList(), repo.getSidCodec());\n                final List<SnapshotId> localSnapshots = localSnapshotsPerWorld.get(worldUuid);\n                remoteSnapshots.retainAll(localSnapshots);\n                if (remoteSnapshots.isEmpty()) {\n                    syslog().warn(\"No common snapshots found between local and remote.\");\n                    syslog().warn(\"Doing a full push.  This may take some time.\");\n                    jgit_doPush(jgit, branchNameToPush, conf, ulog);\n                    return;\n                } else {\n                    Collections.sort(remoteSnapshots);\n                    latestCommonSnapshot = remoteSnapshots.get(remoteSnapshots.size() - 1);\n                    syslog().debug(\"Using existing snapshot \" + latestCommonSnapshot + \" for common history\");\n                }\n            }\n            // ok, we have a common snapshot that we can use to create a fake merge history.\n            final String tempBranchName = \"temp/\" + branchNameToPush;\n            syslog().debug(\"Creating out temp branch \" + tempBranchName);\n            jgit.checkout().setCreateBranch(true).setName(tempBranchName).call();\n            final ObjectId branchId = jgit.getRepository().resolve(latestCommonSnapshot.getBranchName());\n            syslog().debug(\"Merging \" + latestCommonSnapshot.getBranchName());\n            jgit.merge().setContentMergeStrategy(ContentMergeStrategy.OURS).\n                    include(branchId).setMessage(\"Merge \" + branchId + \" into \" + tempBranchName).call();\n            syslog().debug(\"Checking out \" + branchNameToPush);\n            jgit.checkout().setName(branchNameToPush).call();\n            syslog().debug(\"Pushing temp branch \" + tempBranchName);\n            final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitPushProgressMonitor(ulog), 100);\n            final Iterable<PushResult> pushResult = jgit.push().setProgressMonitor(pm).setRemote(remoteName).\n                    setRefSpecs(new RefSpec(tempBranchName + \":\" + tempBranchName),\n                            new RefSpec(branchNameToPush + \":\" + branchNameToPush)).call();\n            syslog().debug(\"Cleaning up branches...\");\n            if (conf.getBoolean(IS_TRACKING_BRANCH_CLEANUP_ENABLED)) {\n                for (final PushResult pr : pushResult) {\n                    for (final TrackingRefUpdate f : pr.getTrackingRefUpdates()) {\n                        final String PREFIX = \"refs/remotes/\";\n                        if (f.getLocalName().startsWith(PREFIX)) {\n                            final String trackingBranchName = f.getLocalName().substring(PREFIX.length());\n                            syslog().debug(\"Cleaning up tracking branch \" + trackingBranchName);\n                            jgit.branchDelete().setForce(true).setBranchNames(trackingBranchName).call();\n                        } else {\n                            syslog().warn(\"Ignoring unrecognized TrackingRefUpdate \" + f.getLocalName());\n                        }\n                    }\n                }\n            }\n            if (conf.getBoolean(IS_TEMP_BRANCH_CLEANUP_ENABLED)) {\n                syslog().debug(\"Deleting local temp branch \" + tempBranchName);\n                jgit.branchDelete().setForce(true).setBranchNames(tempBranchName).call();\n            }\n            if (conf.getBoolean(IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED)) {\n                final String remoteTempBranch = \"refs/heads/\" + tempBranchName;\n                syslog().debug(\"Deleting remote temp branch \" + remoteTempBranch);\n                final RefSpec deleteRemoteBranchSpec = new RefSpec().setSource(null).setDestination(remoteTempBranch);\n                jgit.push().setProgressMonitor(pm).setRemote(remoteName).setRefSpecs(deleteRemoteBranchSpec).call();\n            }\n            syslog().info(\"Push complete\");\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n    }\n\n    // TODO stop passing repo\n    private static boolean doWorldIdCheck(RepoImpl repo, Set<WorldId> remoteWorldUuids) throws IOException {\n        final WorldId localUuid = repo.getWorldId();\n        if (remoteWorldUuids.size() > 2) {\n            syslog().warn(\"Remote has more than one world-id.  This is unusual. \" + remoteWorldUuids);\n        }\n        if (remoteWorldUuids.isEmpty()) {\n            syslog().debug(\"Remote does not have any previously-backed up worlds.\");\n        } else {\n            if (!remoteWorldUuids.contains(localUuid)) {\n                syslog().debug(\"local: \" + localUuid + \", remote: \" + remoteWorldUuids);\n                return false;\n            }\n        }\n        syslog().debug(\"world-id check passed.\");\n        return true;\n    }\n\n    private static URIish getRemoteUri(Git jgit, String remoteName) throws IOException {\n        requireNonNull(jgit);\n        requireNonNull(remoteName);\n        final List<RemoteConfig> remotes;\n        try {\n            remotes = jgit.remoteList().call();\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n        for (final RemoteConfig remote : remotes) {\n            syslog().debug(\"getRemoteUri \" + remote);\n            if (remote.getName().equals(remoteName)) {\n                return remote.getPushURIs() != null && !remote.getURIs().isEmpty() ? remote.getURIs().get(0) : null;\n            }\n        }\n        return null;\n    }\n\n    private static class JGitPushProgressMonitor extends JGitPercentageProgressMonitor {\n\n        private final UserLogger ulog;\n\n        public JGitPushProgressMonitor(UserLogger ulog) {\n            this.ulog = requireNonNull(ulog);\n        }\n\n        @Override\n        public void progressStart(String task) {\n            syslog().debug(task);\n        }\n\n        @Override\n        public void progressUpdate(String task, int percentage) {\n            final String msg = task + \" \" + percentage + \"%\";\n            syslog().debug(msg);\n            ulog.update(styledRaw(msg, JGIT));\n        }\n\n        @Override\n        public void progressDone(String task) {\n            final UserMessage msg = styledLocalized(\"fastback.chat.push-done\", JGIT);\n            syslog().debug(msg.toString());\n            ulog.update(msg);\n        }\n\n        @Override\n        public void showDuration(boolean enabled) {\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/ReclamationUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.utils.FileUtils;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.internal.storage.file.FileRepository;\nimport org.eclipse.jgit.internal.storage.file.GC;\nimport org.eclipse.jgit.lib.ProgressMonitor;\nimport org.eclipse.jgit.lib.Ref;\nimport org.eclipse.jgit.storage.pack.PackConfig;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.text.ParseException;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.function.Consumer;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_BRANCH_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_REFLOG_DELETION_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT;\nimport static net.pcal.fastback.common.logging.UserMessage.raw;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.repo.PushUtils.isTempBranch;\nimport static net.pcal.fastback.common.utils.ProcessUtils.doExec;\nimport static org.apache.commons.io.FileUtils.byteCountToDisplaySize;\nimport static org.apache.commons.io.FileUtils.sizeOfDirectory;\nimport static org.eclipse.jgit.api.ListBranchCommand.ListMode.ALL;\n\n/**\n * Utilities for reclaiming disk space from pruned branches.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class ReclamationUtils {\n\n    static void doReclamation(RepoImpl repo, UserLogger ulog) throws GitAPIException, ProcessException {\n        if (repo.getConfig().getBoolean(IS_NATIVE_GIT_ENABLED)) {\n            native_doLfsPrune(repo, ulog);\n        } else {\n            try {\n                jgit_doGc(repo, ulog);\n            } catch (ParseException | IOException e) {\n                throw new RuntimeException(e);\n            }\n        }\n    }\n\n    private static void native_doLfsPrune(RepoImpl repo, UserLogger ulog) throws ProcessException {\n        final File worktree = repo.getWorkTree();\n        final String[] push = {\"git\", \"-C\", worktree.getAbsolutePath(), \"-c\", \"lfs.pruneoffsetdays=999999\", \"lfs\", \"prune\", \"--verbose\", \"--no-verify-remote\",};\n        final Consumer<String> outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT));\n        doExec(push, Collections.emptyMap(), outputConsumer, outputConsumer);\n        syslog().debug(\"native_doLfsPrune\");\n    }\n\n    /**\n     * Runs git garbage collection.  Aggressively deletes reflogs, tracking branches and stray temporary branches\n     * in an attempt to free up objects and reclaim disk space.\n     */\n    private static void jgit_doGc(RepoImpl repo, UserLogger ulog) throws GitAPIException, ParseException, IOException {\n        final File gitDir = repo.getJGit().getRepository().getDirectory();\n        final GitConfig config = repo.getConfig();\n        ulog.update(styledLocalized(\"fastback.hud.gc-percent\", JGIT, 0));\n        syslog().debug(\"Stats before gc:\");\n        syslog().debug(String.valueOf(repo.getJGit().gc().getStatistics()));\n        final long sizeBeforeBytes = sizeOfDirectory(gitDir);\n        syslog().info(\"Backup size before gc: \" + byteCountToDisplaySize(sizeBeforeBytes));\n        if (config.getBoolean(IS_REFLOG_DELETION_ENABLED)) {\n            // reflogs aren't very useful in our case and cause old snapshots to get retained\n            // longer than people expect.\n            final Path reflogsDir = gitDir.toPath().resolve(\"logs\");\n            syslog().debug(\"Deleting reflogs \" + reflogsDir);\n            FileUtils.rmdir(reflogsDir);\n        }\n        if (config.getBoolean(IS_BRANCH_CLEANUP_ENABLED)) {\n            final List<String> branchesToDelete = new ArrayList<>();\n            for (final Ref ref : repo.getJGit().branchList().setListMode(ALL).call()) {\n                final String branchName = BranchUtils.getBranchName(ref.getName());\n                if (branchName == null) {\n                    syslog().warn(\"Non-branch ref returned by branchList: \" + ref);\n                } else if (isTempBranch(branchName)) {\n                    branchesToDelete.add(branchName);\n                } else if (repo.getSidCodec().isSnapshotBranchName(repo.getWorldId(), branchName)) {\n                    // ok fine\n                } else {\n                    syslog().warn(\"Unidentified branch found \" + branchName + \" - consider removing it with 'git branch -D'\");\n                }\n            }\n            if (branchesToDelete.isEmpty()) {\n                syslog().debug(\"No branches to clean up\");\n            } else {\n                syslog().debug(\"Deleting branches: \" + branchesToDelete);\n                repo.deleteLocalBranches(branchesToDelete);\n                syslog().debug(\"Branches deleted.\");\n            }\n        }\n        final GC gc = new GC(((FileRepository) repo.getJGit().getRepository()));\n        gc.setExpireAgeMillis(0);\n        gc.setPackExpireAgeMillis(0);\n        gc.setAuto(false);\n        final PackConfig pc = new PackConfig();\n        pc.setDeltaCompress(false);\n        gc.setPackConfig(pc);\n        final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new GcProgressMonitor(ulog), 100);\n        gc.setProgressMonitor(pm);\n        syslog().debug(\"Starting garbage collection\");\n        gc.gc(); // TODO progress monitor\n        syslog().debug(\"Garbage collection complete.\");\n        syslog().debug(\"Stats after gc:\");\n        syslog().debug(\"\" + repo.getJGit().gc().getStatistics());\n        final long sizeAfterBytes = sizeOfDirectory(gitDir);\n        syslog().info(\"Backup size after gc: \" + byteCountToDisplaySize(sizeAfterBytes));\n    }\n\n    private static class GcProgressMonitor extends JGitPercentageProgressMonitor {\n\n        private final UserLogger ulog;\n\n        public GcProgressMonitor(UserLogger ulog) {\n            this.ulog = requireNonNull(ulog);\n        }\n\n        @Override\n        public void progressStart(String task) {\n            this.ulog.update(raw(task));\n        }\n\n        @Override\n        public void progressUpdate(String task, int percentage) {\n            final String message = task + \" \" + percentage + \"%\";\n            syslog().debug(message);\n            this.ulog.update(styledLocalized(message, JGIT));\n        }\n\n        @Override\n        public void progressDone(String task) {\n            final UserMessage msg = styledLocalized(\"fastback.chat.gc-done-no-reclaim\", JGIT);\n            syslog().debug(msg.toString());\n            ulog.update(msg);\n        }\n\n        @Override\n        public void showDuration(boolean enabled) {\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/Repo.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.errors.NoWorkTreeException;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.text.ParseException;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Set;\n\n/**\n * Encapsulates everything the mod needs to do to the git repo.\n *\n * @author pcal\n * @since 0.13.0\n */\npublic interface Repo extends AutoCloseable {\n\n\n    Set<SnapshotId> getLocalSnapshots() throws IOException;\n\n    Set<SnapshotId> getRemoteSnapshots() throws IOException;\n\n    // ======================================================================\n    // 'do' methods.\n    //\n    // By convention, methods prefixed with 'do' provide the 'guts' of a flow\n    // initiated by a cli command or scheduled action.  They're expected to handle\n    // everything: errors, user feedback.  A method prefixed with 'do' must return\n    // void and must not throw checked exceptions.\n    //\n    // q: should they also be responsible for thread management?  probably yes\n    // Obviously there are still some TODOs here to align with this convention.\n    //\n\n    void doCommitAndPush(UserLogger ulog) throws IOException;\n\n    void doCommitSnapshot(UserLogger ulog) throws IOException;\n\n    Collection<SnapshotId> doLocalPrune(UserLogger ulog) throws IOException;\n\n    Collection<SnapshotId> doRemotePrune(UserLogger ulog) throws IOException;\n\n    void doRestoreLocalSnapshot(String snapshotName, UserLogger ulog);\n\n    void doRestoreRemoteSnapshot(String snapshotName, UserLogger ulog);\n\n    void doGc(UserLogger ulog);\n\n    void doPushSnapshot(SnapshotId sid, UserLogger ulog);\n\n    void deleteRemoteBranch(String remoteBranchName) throws IOException;\n\n    void deleteLocalBranches(List<String> branchesToDelete) throws GitAPIException, IOException;\n\n    // ======================================================================\n    // Any callers of these methods are doing too much; they need to be given a\n    // 'do' method instead\n\n    @Deprecated\n    SnapshotId createSnapshotId(String date) throws IOException, ParseException;\n\n    @Deprecated\n    GitConfig getConfig();\n\n    @Deprecated\n    WorldId getWorldId() throws IOException;\n\n    @Deprecated\n    File getDirectory() throws NoWorkTreeException;\n\n    @Deprecated\n    File getWorkTree() throws NoWorkTreeException;\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/RepoFactory.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\n\n/**\n * Creates Repo instances.\n *\n * @author pcal\n * @since 0.13.0\n */\npublic interface RepoFactory {\n\n    // TODO this probably should move to ModContext\n    static RepoFactory rf() {\n        return new RepoFactoryImpl();\n    }\n\n    void doInit(Path worldSaveDir, UserLogger ulog) throws IOException;\n\n    Repo load(final Path worldSaveDir) throws IOException;\n\n    boolean doInitCheck(Path worldSaveDir, UserLogger ulog);\n\n    boolean isGitRepo(Path worldSaveDir);\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/RepoFactoryImpl.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig.Updater;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.lib.StoredConfig;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Path;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.OtherConfigKey.COMMIT_SIGNING_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING;\nimport static net.pcal.fastback.common.logging.UserMessage.raw;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.repo.WorldIdUtils.createWorldId;\nimport static net.pcal.fastback.common.repo.WorldIdUtils.ensureWorldHasId;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk;\n\n/**\n * @author pcal\n * @since 0.13.0\n */\nclass RepoFactoryImpl implements RepoFactory {\n\n    @Override\n    public void doInit(final Path worldSaveDir, final UserLogger ulog) throws IOException {\n        if (isGitRepo(worldSaveDir)) {\n            ensureWorldHasId(worldSaveDir);\n            ulog.message(styledLocalized(\"fastback.chat.enabled\", WARNING));\n            return;\n        }\n        // If they haven't yet run 'backup init', make sure they've installed native.\n        if (!isNativeOk(true, ulog, true)) throw new IOException();\n        try (final Git jgit = Git.init().setDirectory(worldSaveDir.toFile()).call()) {\n            createWorldId(worldSaveDir);\n            Repo repo = new RepoImpl(jgit);\n            final Updater updater = repo.getConfig().updater();\n            updater.set(COMMIT_SIGNING_ENABLED, false); // because some people have it set globally\n            updater.set(IS_NATIVE_GIT_ENABLED, true);\n\n            StoredConfig config = jgit.getRepository().getConfig();\n            String userName = config.getString(\"user\", null, \"name\");\n            String userEmail = config.getString(\"user\", null, \"email\");\n\n            // If they don't have name/email set (as most non-git-users won't), provide synthetic values as a convenience.\n            // Presumbably, most folks don't care.\n            if (userName == null || userName.isEmpty() || userEmail == null || userEmail.isEmpty()) {\n                // set from fastback world id\n                String worldId = WorldIdUtils.getWorldIdInfo(worldSaveDir).wid().toString();\n                config.setString(\"user\", null, \"name\", worldId);\n                config.setString(\"user\", null, \"email\", worldId + \"@fastback\");\n            }\n            updater.save();\n            ulog.message(raw(\"Backups initialized.  Run '/backup local' to do your first backup.  '/backup help' for more options.\"));\n        } catch (GitAPIException e) {\n            syslog().error(\"Error initializing repo\", e);\n            throw new IOException(e);\n        }\n    }\n\n    @Override\n    public boolean doInitCheck(Path worldSaveDir, UserLogger ulog) {\n        if (!isGitRepo(worldSaveDir)) {\n            ulog.message(styledRaw(\"Please run '/backup init' first.\", ERROR));\n            return false;\n        }\n        return true;\n    }\n\n    @Override\n    public Repo load(final Path worldSaveDir) throws IOException {\n        final Git jgit = Git.open(worldSaveDir.toFile());\n        // It should already be there.  But let's try to be extra sure this is there, because lots of stuff\n        // will blow up if it's missing.\n        ensureWorldHasId(worldSaveDir);\n        return new RepoImpl(jgit);\n    }\n\n    @Override\n    public boolean isGitRepo(final Path worldSaveDir) {\n        final File dotGit = worldSaveDir.resolve(\".git\").toFile();\n        return dotGit.exists() && dotGit.isDirectory();\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/RepoImpl.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec;\nimport net.pcal.fastback.common.repo.WorldIdUtils.WorldIdInfo;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.errors.NoWorkTreeException;\nimport org.eclipse.jgit.lib.Ref;\nimport org.eclipse.jgit.util.FileUtils;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.text.ParseException;\nimport java.time.Duration;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Set;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_LOCK_CLEANUP_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.BROADCAST;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.common.repo.PushUtils.jgit_lsRemote;\nimport static net.pcal.fastback.common.repo.PushUtils.native_lsRemote;\nimport static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk;\nimport static org.eclipse.jgit.util.FileUtils.RETRY;\n\n/**\n * @author pcal\n * @since 0.13.0\n */\nclass RepoImpl implements Repo {\n\n    // ======================================================================\n    // Constants\n\n    static final String FASTBACK_DIR = \".fastback\";\n\n    // ======================================================================\n    // Fields\n\n    private final Git jgit;\n    private GitConfig config;\n    private WorldIdInfo worldIdInfo;\n\n    // ======================================================================\n    // Constructors\n\n    RepoImpl(final Git jgit) {\n        this.jgit = requireNonNull(jgit);\n    }\n\n    // ======================================================================\n    // 'do' methods - implement higher-level command-oriented logic.\n\n    @Override\n    public void doCommitAndPush(final UserLogger ulog) {\n        if (!isNativeOk(this.getConfig(), ulog, false)) return;\n        checkIndexLock(ulog);\n        broadcastBackupNotice();\n        final long start = System.currentTimeMillis();\n        final SnapshotId newSid;\n        try {\n            newSid = CommitUtils.doCommitSnapshot(this, ulog);\n        } catch (IOException | GitAPIException | ProcessException e) {\n            syslog().error(e);\n            ulog.message(styledLocalized(\"fastback.chat.commit-failed\", ERROR));\n            return;\n        }\n        try {\n            PushUtils.doPush(newSid, this, ulog);\n        } catch (IOException | ProcessException e) {\n            ulog.message(styledLocalized(\"fastback.chat.push-failed\", ERROR));\n            syslog().error(e);\n            return;\n        }\n        ulog.message(localized(\"fastback.chat.backup-complete-elapsed\", getDuration(start)));\n    }\n\n    @Override\n    public void doCommitSnapshot(final UserLogger ulog) {\n        if (!isNativeOk(this.getConfig(), ulog, false)) return;\n        checkIndexLock(ulog);\n        broadcastBackupNotice();\n        final long start = System.currentTimeMillis();\n        final SnapshotId newSid;\n        try {\n            newSid = CommitUtils.doCommitSnapshot(this, ulog);\n        } catch (IOException | ProcessException | GitAPIException e) {\n            ulog.message(styledLocalized(\"fastback.chat.commit-failed\", ERROR));\n            syslog().error(e);\n            return;\n        }\n        ulog.message(localized(\"fastback.chat.backup-complete-elapsed\", getDuration(start)));\n    }\n\n    @Override\n    public void doPushSnapshot(SnapshotId sid, final UserLogger ulog) {\n        if (!this.getConfig().isSet(REMOTE_PUSH_URL)) {\n            ulog.message(styledLocalized(\"fastback.chat.remote-no-url\", ERROR));\n            return;\n        }\n        if (!isNativeOk(this.getConfig(), ulog, false)) return;\n        final long start = System.currentTimeMillis();\n        try {\n            PushUtils.doPush(sid, this, ulog);\n        } catch (IOException | ProcessException e) {\n            ulog.message(styledLocalized(\"fastback.chat.commit-failed\", ERROR));\n            syslog().error(e);\n            return;\n        }\n        ulog.message(UserMessage.localized(\"fastback.chat.push-done-elapsed\", sid.getShortName(), getDuration(start)));\n    }\n\n\n    @Override\n    public Collection<SnapshotId> doLocalPrune(final UserLogger ulog) throws IOException {\n        return PruneUtils.doLocalPrune(this, ulog);\n    }\n\n    @Override\n    public Collection<SnapshotId> doRemotePrune(final UserLogger ulog) throws IOException {\n        return PruneUtils.doRemotePrune(this, ulog);\n    }\n\n    @Override\n    public void doGc(final UserLogger ulog) {\n        if (!isNativeOk(this.getConfig(), ulog, false)) return;\n        try {\n            ReclamationUtils.doReclamation(this, ulog);\n        } catch (ProcessException | GitAPIException e) {\n            ulog.message(styledLocalized(\"fastback.chat.gc-failed\", ERROR));\n            syslog().error(e);\n        }\n    }\n\n    @Override\n    public void doRestoreLocalSnapshot(String snapshotName, UserLogger ulog) {\n        RestoreUtils.doRestoreLocalSnapshot(snapshotName, this, ulog);\n    }\n\n    @Override\n    public void doRestoreRemoteSnapshot(String snapshotName, UserLogger ulog) {\n        RestoreUtils.doRestoreRemoteSnapshot(snapshotName, this, ulog);\n    }\n\n    // ======================================================================\n    // Other repo implementation\n\n    @Override\n    public WorldId getWorldId() throws IOException {\n        return WorldIdUtils.getWorldIdInfo(this.getWorkTree().toPath()).wid();\n    }\n\n    @Override\n    public Set<SnapshotId> getLocalSnapshots() throws IOException {\n        final JGitSupplier<Collection<String>> refProvider = () -> {\n            try {\n                return jgit.branchList().call().stream().map(Ref::getName).toList();\n            } catch (GitAPIException e) {\n                throw new IOException(e);\n            }\n        };\n        try {\n            return BranchUtils.listSnapshots(this, refProvider);\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n    }\n\n    @Override\n    public Set<SnapshotId> getRemoteSnapshots() throws IOException {\n        final GitConfig conf = GitConfig.load(jgit);\n        final String remoteName = conf.getString(REMOTE_NAME);\n        final JGitSupplier<Collection<String>> refProvider = () -> {\n            try {\n                if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                    return native_lsRemote(this.getWorkTree().toPath(), remoteName);\n                } else {\n                    return jgit_lsRemote(this.jgit, remoteName);\n                }\n            } catch (GitAPIException | ProcessException e) {\n                throw new IOException(e);\n            }\n        };\n        try {\n            return BranchUtils.listSnapshots(this, refProvider);\n        } catch (GitAPIException e) {\n            throw new IOException(e);\n        }\n    }\n\n    @Override\n    public GitConfig getConfig() {\n        if (this.config == null) {\n            this.config = GitConfig.load(this.jgit);\n        }\n        return this.config;\n    }\n\n    @Override\n    public File getDirectory() throws NoWorkTreeException {\n        return this.jgit.getRepository().getDirectory();\n    }\n\n    @Override\n    public File getWorkTree() throws NoWorkTreeException {\n        return this.jgit.getRepository().getWorkTree();\n    }\n\n    @Override\n    public void deleteRemoteBranch(String remoteBranchName) throws IOException {\n        PruneUtils.deleteRemoteBranch(this, remoteBranchName);\n    }\n\n    @Override\n    public void deleteLocalBranches(final List<String> branchesToDelete) throws IOException {\n        PruneUtils.deleteLocalBranches(this, branchesToDelete);\n    }\n\n    @Override\n    public void close() {\n        this.getJGit().close();\n    }\n\n    @Override\n    public SnapshotId createSnapshotId(String shortName) throws IOException, ParseException {\n        return getWorldIdInfo().sidCodec().create(this.getWorldId(), shortName);\n    }\n\n    // ======================================================================\n    // Package-private\n\n    SnapshotIdCodec getSidCodec() throws IOException {\n        return this.getWorldIdInfo().sidCodec();\n    }\n\n    Git getJGit() {\n        return this.jgit;\n    }\n\n    Path getDotFasbackDir() {\n        return this.getWorkTree().toPath().resolve(FASTBACK_DIR);\n    }\n\n    // ======================================================================\n    // Private\n\n    private WorldIdInfo getWorldIdInfo() throws IOException {\n        if (this.worldIdInfo == null) {\n            this.worldIdInfo = WorldIdUtils.getWorldIdInfo(this.getWorkTree().toPath());\n        }\n        return this.worldIdInfo;\n    }\n\n    private void checkIndexLock(UserLogger ulog) {\n        final File lockFile = this.getWorkTree().toPath().resolve(\".git/index.lock\").toFile();\n        if (lockFile.exists()) {\n            ulog.message(styledLocalized(\"fastback.chat.lockfile-exists\", WARNING, lockFile.getAbsolutePath()));\n            if (getConfig().getBoolean(IS_LOCK_CLEANUP_ENABLED)) {\n                ulog.message(styledLocalized(\"fastback.chat.lockfile-cleanup-enabled\", WARNING, \"lock-cleanup-enabled = true\"));\n                try {\n                    FileUtils.delete(lockFile, RETRY);\n                } catch (IOException e) {\n                    syslog().debug(e); // we kind of don't care\n                }\n                if (lockFile.exists()) {\n                    ulog.message(styledRaw(\"Cleanup failed.  Your backup will probably not succeed.\", ERROR));\n                } else {\n                    ulog.message(styledRaw(\"Cleanup succeeded, proceeding with backup.  But if you see this message again, you should check your system to see if some other git process is accessing your backup.\", WARNING));\n                }\n            } else {\n                ulog.message(styledRaw(\"Please check to see if other processes are using this git repo.  If you're sure they aren't, you can enable automatic index.lock cleanup by typing '/set lock-cleanup enabled'\", WARNING));\n                ulog.message(styledRaw(\"Proceeding with backup but it will probably not succeed.\", WARNING));\n            }\n        }\n    }\n\n    private static String getDuration(long since) {\n        final Duration d = Duration.of(System.currentTimeMillis() - since, ChronoUnit.MILLIS);\n        long seconds = d.getSeconds();\n        if (seconds < 60) {\n            return String.format(\"%ds\", seconds == 0 ? 1 : seconds);\n        } else {\n            return String.format(\"%dm %ds\", seconds / 60, seconds % 60);\n        }\n    }\n\n    private void broadcastBackupNotice() {\n        if (!getConfig().getBoolean(BROADCAST_ENABLED)) return;\n        final UserMessage m;\n        final String configuredMessage = getConfig().getString(BROADCAST_MESSAGE);\n        if (configuredMessage != null) {\n            m = styledRaw(configuredMessage, BROADCAST);\n        } else {\n            m = styledLocalized(\"fastback.broadcast.message\", BROADCAST);\n        }\n        mod().sendBroadcast(m);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/RestoreUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\nimport net.pcal.fastback.common.logging.UserMessage.UserMessageStyle;\nimport net.pcal.fastback.common.utils.FileUtils;\nimport net.pcal.fastback.common.utils.ProcessException;\nimport net.pcal.fastback.common.utils.ProcessUtils;\nimport org.eclipse.jgit.api.Git;\nimport org.eclipse.jgit.api.errors.GitAPIException;\nimport org.eclipse.jgit.lib.ProgressMonitor;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY;\nimport static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledRaw;\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Utilities for restoring a snapshot\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class RestoreUtils {\n\n    // ======================================================================\n    // Package private\n\n    static void doRestoreLocalSnapshot(final String snapshotNameToRestore, final RepoImpl repo, final UserLogger ulog) {\n        doRestoreSnapshot(snapshotNameToRestore, \"file://\" + mod().getWorldDirectory().toAbsolutePath(), repo, ulog);\n    }\n\n    static void doRestoreRemoteSnapshot(final String snapshotNameToRestore, final RepoImpl repo, final UserLogger ulog) {\n        final GitConfig conf = repo.getConfig();\n        if (!conf.isSet(REMOTE_PUSH_URL)) {\n            ulog.message(styledLocalized(\"fastback.chat.remote-no-url\", ERROR));\n        } else {\n            doRestoreSnapshot(snapshotNameToRestore, conf.getString(REMOTE_PUSH_URL), repo, ulog);\n        }\n    }\n\n    // ======================================================================\n    // Private\n\n    private static void doRestoreSnapshot(final String snapshotNameToRestore, final String repoUri, final RepoImpl repo, final UserLogger ulog) {\n        try {\n            PreflightUtils.doPreflight(repo);\n            final GitConfig conf = repo.getConfig();\n            final SnapshotId sid = repo.createSnapshotId(snapshotNameToRestore);\n            final Path allRestoresDir = conf.isSet(RESTORE_DIRECTORY) ?\n                    Paths.get(conf.getString(RESTORE_DIRECTORY)) : mod().getDefaultRestoresDir();\n            final Path restoreTargetDir = getTargetDir(allRestoresDir, mod().getWorldName(), sid.getShortName());\n            if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) {\n                native_restoreSnapshot(sid.getBranchName(), restoreTargetDir, repoUri, ulog);\n            } else {\n                jgit_restoreSnapshot(sid.getBranchName(), restoreTargetDir, repoUri, ulog);\n            }\n            ulog.message(localized(\"fastback.chat.restore-done\", restoreTargetDir));\n        } catch (Exception e) {\n            syslog().error(e);\n            ulog.message(styledRaw(\"Restore failed.  See log for details.\", ERROR)); // FIXME i18n\n        }\n    }\n\n    private static void native_restoreSnapshot(final String branchName, final Path restoreTargetDir, final String repoUri, final UserLogger ulog) throws ProcessException {\n        final Map<String, String> env = Map.of(\"GIT_LFS_FORCE_PROGRESS\", \"1\");\n        final Consumer<String> outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT));\n        final String restoreTargetDirStr = restoreTargetDir.toString();\n        syslog().debug(\"Cloning repo at \" + repoUri);\n        ProcessUtils.doExec(new String[]{\n                \"git\", \"clone\", repoUri, \"--no-checkout\", \"--branch\", branchName, \"--single-branch\", restoreTargetDirStr\n        }, env, outputConsumer, outputConsumer);\n        syslog().debug(\"Installing lfs locally in \" + restoreTargetDirStr);\n        ProcessUtils.doExec(new String[]{\n                \"git\", \"-C\", restoreTargetDirStr, \"lfs\", \"install\", \"--local\"\n        }, env, outputConsumer, outputConsumer);\n        syslog().debug(\"Checking out \" + branchName + \", downloading lfs blobs\");\n        ProcessUtils.doExec(new String[]{\n                \"git\", \"-C\", restoreTargetDirStr, \"checkout\", branchName\n        }, env, outputConsumer, outputConsumer);\n    }\n\n    private static void jgit_restoreSnapshot(final String branchName, final Path restoreTargetDir, final String repoUri, final UserLogger ulog) throws IOException, GitAPIException {\n        ulog.update(localized(\"fastback.hud.restore-percent\", 0));\n        final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitRestoreProgressMonitor(ulog), 100);\n        try (Git git = Git.cloneRepository().setProgressMonitor(pm).setDirectory(restoreTargetDir.toFile()).\n                setBranchesToClone(List.of(\"refs/heads/\" + branchName)).setBranch(branchName).setURI(repoUri).call()) {\n        }\n        FileUtils.rmdir(restoreTargetDir.resolve(\".git\"));\n    }\n\n    /**\n     * @param allRestoresDir - general location for restorations to go.  e.g., the 'saves' dir by default if client\n     * @param worldName      - name of the world\n     * @param snapshotName   - name of the snapshot being restored\n     * @return The absolute path to the directory where the snapshot should be restored\n     */\n    private static Path getTargetDir(Path allRestoresDir, String worldName, String snapshotName) {\n        worldName = worldName.replaceAll(\"\\\\W+\", \"\"); // strip out all non-word characters for safety\n        Path base = allRestoresDir.resolve(worldName + \"-\" + snapshotName);\n        Path candidate = base;\n        int i = 0;\n        while (candidate.toFile().exists()) {\n            i++;\n            candidate = Path.of(base + \"_\" + i);\n            if (i > 1000) {\n                throw new IllegalStateException(\"wat i = \" + i);\n            }\n        }\n        return candidate;\n    }\n\n    private static class JGitRestoreProgressMonitor extends JGitPercentageProgressMonitor {\n\n        private final UserLogger ulog;\n\n        public JGitRestoreProgressMonitor(UserLogger ulog) {\n            this.ulog = requireNonNull(ulog);\n        }\n\n        @Override\n        public void progressStart(String task) {\n        }\n        //remote: Finding sources\n        //Receiving objects\n        //Updating references\n        //Checking out files   %\n\n        @Override\n        public void progressUpdate(String task, int percentage) {\n            final String message = task + \" \" + percentage + \"%\";\n            syslog().debug(message);\n            this.ulog.update(styledRaw(message, UserMessageStyle.JGIT));\n        }\n\n        @Override\n        public void progressDone(String task) {\n        }\n\n        @Override\n        public void showDuration(boolean enabled) {\n        }\n\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/SnapshotId.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\n\nimport java.util.Date;\n\n/**\n * A globally-unique-ish identifier for a single backup snapshot.\n *\n * @author pcal\n */\npublic interface SnapshotId extends Comparable<SnapshotId> {\n\n    // ====================================================================\n    // Accessors\n\n    String getShortName();\n\n    Date getDate();\n\n    String getBranchName();\n\n    WorldId getWorldId();\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/SnapshotIdUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport com.google.common.collect.ArrayListMultimap;\nimport com.google.common.collect.ListMultimap;\nimport net.pcal.fastback.common.repo.WorldIdUtils.WorldIdImpl;\n\nimport java.text.DateFormat;\nimport java.text.ParseException;\nimport java.text.SimpleDateFormat;\nimport java.util.Date;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\nabstract class SnapshotIdUtils {\n\n    static ListMultimap<WorldId, SnapshotId> getSnapshotsPerWorld(Iterable<String> refs, SnapshotIdCodec codec) {\n        final ListMultimap<WorldId, SnapshotId> out = ArrayListMultimap.create();\n        for (final String ref : refs) {\n            final String branchName = BranchUtils.getBranchName(ref);\n            if (branchName == null) continue;\n            try {\n                final SnapshotId sid = codec.fromBranch(branchName);\n                if (sid != null) out.put(sid.getWorldId(), sid);\n            } catch (ParseException e) {\n                syslog().warn(\"Ignoring unexpected branch name \" + branchName);\n            }\n        }\n        return out;\n    }\n\n    enum SnapshotIdCodec {\n\n        V2 {\n            private static final String SEP = \"/\";\n\n            @Override\n            SnapshotId create(final WorldId wid) {\n                final Date date = new Date();\n                final String shortName = DATE_FORMAT.format(date);\n                return new SnapshotIdImpl(wid, date, shortName, getBranchName(wid, shortName));\n            }\n\n            @Override\n            SnapshotId create(final WorldId wid, String shortName) throws ParseException {\n                return new SnapshotIdImpl(wid, DATE_FORMAT.parse(shortName), shortName, getBranchName(wid, shortName));\n            }\n\n            @Override\n            boolean isSnapshotBranchName(WorldId wid, final String branchName) {\n                return branchName.startsWith(wid + SEP);\n            }\n\n            @Override\n            SnapshotId fromBranch(final String rawBranchName) throws ParseException {\n                final String[] segments = rawBranchName.split(SEP);\n                if (segments.length != 2) {\n                    throw new ParseException(\"Wrong number of segments\" + rawBranchName, segments.length);\n                }\n                final WorldId worldId = new WorldIdImpl(segments[0]);\n                final Date date = DATE_FORMAT.parse(segments[1]);\n                final String shortName = DATE_FORMAT.format(date);\n                return new SnapshotIdImpl(worldId, date, shortName, rawBranchName);\n            }\n\n            private static String getBranchName(WorldId wid, String shortName) {\n                return wid + SEP + shortName;\n            }\n        },\n\n\n        V1 {\n\n            private static final String PREFIX = \"snapshots\";\n            private static final String SEP = \"/\";\n\n            @Override\n            SnapshotId create(WorldId wid) {\n                final Date date = new Date();\n                final String shortName = DATE_FORMAT.format(date);\n                return new SnapshotIdImpl(wid, date, shortName, getBranchName(wid, shortName));\n            }\n\n\n            @Override\n            SnapshotId create(WorldId wid, String shortName) throws ParseException {\n                return new SnapshotIdImpl(wid, DATE_FORMAT.parse(shortName), shortName, getBranchName(wid, shortName));\n            }\n\n            @Override\n            boolean isSnapshotBranchName(WorldId bid, String branchName) {\n                return branchName.startsWith(PREFIX + SEP + bid);\n            }\n\n            //Committing snapshots/06628b24-118c-42ae-8cce-5d131a94c7ee/2022-09-12_23-24-50\n            @Override\n            SnapshotId fromBranch(String rawBranchName) throws ParseException {\n                if (!rawBranchName.startsWith(PREFIX + SEP)) {\n                    throw new ParseException(\"Not a snapshot branch \" + rawBranchName, 0);\n                }\n                final String[] segments = rawBranchName.split(SEP);\n                if (segments.length < 3) {\n                    throw new ParseException(\"too few segments \" + rawBranchName, segments.length);\n                }\n                final WorldId worldUuid = new WorldIdImpl(segments[1]);\n                final Date date = DATE_FORMAT.parse(segments[2]);\n                final String shortName = DATE_FORMAT.format(date);\n                return new SnapshotIdImpl(worldUuid, date, shortName, rawBranchName);\n            }\n\n            private static String getBranchName(WorldId wid, String shortName) {\n                return PREFIX + SEP + wid + SEP + shortName;\n            }\n        };\n\n\n        static final DateFormat DATE_FORMAT = new SimpleDateFormat(\"yyyy-MM-dd_HH-mm-ss\");\n\n        abstract SnapshotId create(WorldId wid);\n\n        abstract SnapshotId create(WorldId worldId, String shortName) throws ParseException;\n\n        abstract SnapshotId fromBranch(String rawBranchName) throws ParseException;\n\n        abstract boolean isSnapshotBranchName(WorldId bid, String branchName);\n\n    }\n\n    public record SnapshotIdImpl(WorldId worldUuid, Date date, String shortName,\n                                 String branchName) implements SnapshotId {\n\n        // ====================================================================\n        // Accessors\n\n        public String getShortName() {\n            return shortName;\n        }\n\n        @Override\n        public Date getDate() {\n            return date;\n        }\n\n        public String getBranchName() {\n            return branchName;\n        }\n\n        @Override\n        public WorldId getWorldId() {\n            return worldUuid;\n        }\n\n        @Override\n        public int compareTo(final SnapshotId o) {\n            return this.date.compareTo(o.getDate());\n        }\n\n        @Override\n        public String toString() {\n            return branchName;\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/WorldId.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\n/**\n * @author pcal\n * @since 0.14.0\n */\npublic interface WorldId {\n\n    String toString();\n\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/repo/WorldIdUtils.java",
    "content": "package net.pcal.fastback.common.repo;\n\nimport net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec;\nimport net.pcal.fastback.common.utils.FileUtils;\n\nimport java.io.File;\nimport java.io.FileNotFoundException;\nimport java.io.FileWriter;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Random;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Utils for managing the world.id file, which uniquely identifies a given world\n * for backup purposes.  The basic idea is we want to help them avoid mixing snapshots\n * from different worlds in the same remote repository, since that will be painful to\n * untangle and be pretty inefficient.\n *\n * @author pcal\n * @since 0.13.0\n */\nabstract class WorldIdUtils {\n\n    // ==============================A========================================\n    // Constants\n\n    /**\n     * Path to where we store a short, unique-ish identifier for this world\n     */\n    private static final Path WORLD_ID_PATH = Path.of(\".fastback/world-id\");\n\n    /**\n     * Character length of randomly-generated world id's.  58^4 seems good\n     * enough for our purposes.\n     */\n    private static final int WORLD_ID_LENGTH = 4;\n\n    /**\n     * Path where we used to store the world id.  (pre-0.15)\n     */\n    @Deprecated\n    private static final Path OLD_WORLD_UUID_PATH = Path.of(\".fastback/world.uuid\");\n\n    // ======================================================================\n    // Utils\n\n    record WorldIdInfo(WorldId wid, SnapshotIdCodec sidCodec) {\n    }\n\n    /**\n     * @return the WorldId and the SnapshotIdCodec to use.  Never returns null (though the worldId might be null).\n     */\n    static WorldIdInfo getWorldIdInfo(final Path worldSaveDir) throws IOException {\n        migrateFastbackDir(worldSaveDir);\n        {\n            final Path idPath = worldSaveDir.resolve(WORLD_ID_PATH);\n            if (idPath.toFile().exists()) {\n                final WorldId wid = new WorldIdImpl(requireNonNull(Files.readString(idPath).trim()));\n                return new WorldIdInfo(wid, SnapshotIdCodec.V2);\n            }\n        }\n        {\n            final Path uuidPath = worldSaveDir.resolve(OLD_WORLD_UUID_PATH);\n            if (uuidPath.toFile().exists()) {\n                final WorldId wid = new WorldIdImpl(requireNonNull(Files.readString(uuidPath).trim()));\n                return new WorldIdInfo(wid, SnapshotIdCodec.V1);\n            }\n\n        }\n        throw new FileNotFoundException(WORLD_ID_PATH.toString());\n    }\n\n    static void createWorldId(final Path worldSaveDir) throws IOException {\n        migrateFastbackDir(worldSaveDir);\n        final Path worldIdPath = worldSaveDir.resolve(WORLD_ID_PATH).toAbsolutePath().normalize();\n        if (worldIdPath.toFile().exists()) {\n            syslog().debug(worldIdPath + \" already exists, skipping id creation\");\n            return;\n        }\n        FileUtils.mkdirs(worldIdPath.getParent());\n        final String worldId = generateRandomWorldId(WORLD_ID_LENGTH);\n        try (final FileWriter fw = new FileWriter(worldIdPath.toFile())) {\n            fw.append(worldId);\n            fw.append('\\n');\n        }\n        syslog().debug(\"Wrote new worldId \" + worldId + \" to \" + worldIdPath);\n    }\n\n    static void ensureWorldHasId(final Path worldSaveDir) throws IOException {\n        migrateFastbackDir(worldSaveDir);\n        final Path worldIdPath = worldSaveDir.resolve(WORLD_ID_PATH).toAbsolutePath().normalize();\n        if (!worldIdPath.toFile().exists()) {\n            syslog().warn(\"Did not find expected id file at \" + worldIdPath);\n            syslog().warn(\"We'll create a new one and carry on.  But this indicates something weird is going on.\");\n            createWorldId(worldSaveDir);\n        }\n    }\n\n    record WorldIdImpl(String id) implements WorldId {\n        @Override\n        public String toString() {\n            return id;\n        }\n    }\n\n    // ======================================================================\n    // Exposed for unit-testing\n\n    static String generateRandomWorldId(int length) {\n        final StringBuilder out = new StringBuilder();\n        final Random r = new Random();\n        for (int i = 0; i < length; i++) {\n            out.append(BASE58_CHARS[r.nextInt(BASE58_CHARS.length)]);\n        }\n        return out.toString();\n    }\n\n    // ======================================================================\n    // Private\n\n    private static final char[] BASE58_CHARS =\n            \"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\".toCharArray();\n\n    /**\n     * Generate a random ID string of the given length.\n     */\n    private static void migrateFastbackDir(final Path worldSaveDir) {\n        final File oldDir = worldSaveDir.resolve(\"fastback\").toAbsolutePath().normalize().toFile();\n        if (oldDir.exists()) {\n            final File newDir = worldSaveDir.resolve(\".fastback\").toAbsolutePath().normalize().toFile();\n            if (!newDir.exists()) {\n                if (oldDir.renameTo(newDir)) {\n                    syslog().info(\"moved \" + oldDir + \" to \" + newDir);\n                } else {\n                    syslog().error(\"failed to move \" + oldDir + \" to \" + newDir);\n                }\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/AllRetentionPolicy.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\n/**\n * Policy to retain all snapshots.\n *\n * @author pcal\n * @since 0.2.0\n */\nenum AllRetentionPolicy implements RetentionPolicy {\n\n    INSTANCE;\n\n    private static final String L10N_KEY = \"fastback.retain.all.description\";\n\n    @Override\n    public UserMessage getDescription() {\n        return UserMessage.localized(L10N_KEY);\n    }\n\n    @Override\n    public Collection<SnapshotId> getSnapshotsToPrune(Set<SnapshotId> fromSnapshots) {\n        return Collections.emptySet();\n    }\n\n    enum Type implements RetentionPolicyType {\n\n        INSTANCE;\n\n        @Override\n        public String getName() {\n            return \"all\";\n        }\n\n        @Override\n        public List<Parameter<?>> getParameters() {\n            return Collections.emptyList();\n        }\n\n        @Override\n        public RetentionPolicy createPolicy(final Map<String, String> config) {\n            return AllRetentionPolicy.INSTANCE;\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.localized(L10N_KEY);\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/DailyRetentionPolicy.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport com.mojang.brigadier.arguments.IntegerArgumentType;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.time.LocalDate;\nimport java.time.Period;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.TimeZone;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Policy that retains only the last snapshot of each day, along with all snapshots in\n * the last n days.\n *\n * @author pcal\n * @since 0.2.0\n */\nclass DailyRetentionPolicy implements RetentionPolicy {\n\n    private static final String GRACE_PERIOD_DAYS = \"gracePeriodDays\";\n    private static final int DEFAULT_GRACE_PERIOD_DAYS = 3;\n    private static final String L10N_KEY = \"fastback.retain.daily.description\";\n\n    private final int gracePeriod;\n\n    public DailyRetentionPolicy(int gracePeriod) {\n        this.gracePeriod = gracePeriod;\n    }\n\n    @Override\n    public UserMessage getDescription() {\n        return UserMessage.localized(L10N_KEY, gracePeriod);\n    }\n\n    @Override\n    public Collection<SnapshotId> getSnapshotsToPrune(Set<SnapshotId> snapshots) {\n        final LocalDate today = LocalDate.now(TimeZone.getDefault().toZoneId());\n        final LocalDate gracePeriodStart = today.minus(Period.ofDays(gracePeriod));\n        final List<SnapshotId> toPrune = new ArrayList<>();\n        LocalDate previousDate = null;\n        List<SnapshotId> sortedDesending = new ArrayList<>(snapshots);\n        Collections.sort(sortedDesending, Collections.reverseOrder());\n        for (final SnapshotId sid : sortedDesending) {\n            final LocalDate currentDate = sid.getDate().toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDate();\n            if (previousDate != null) {\n                if (currentDate.isAfter(gracePeriodStart)) {\n                    syslog().debug(\"Will retain \" + sid + \" because still in the grace period\");\n                    continue;\n                }\n                if (currentDate.equals(previousDate)) {\n                    syslog().debug(\"Will prune \" + sid + \" same day as \" + currentDate);\n                    toPrune.add(sid);\n                } else {\n                    syslog().debug(\"Will retain \" + sid + \" NOT same day as \" + currentDate);\n                }\n            }\n            previousDate = currentDate;\n        }\n        return toPrune;\n    }\n\n    /**\n     * Retention policy that keeps only the most-recent snapshot of each day.  Provides for a grace period\n     * during which all snapshots are retained.\n     *\n     * @author pcal\n     * @since 0.2.0\n     */\n    public enum DailyRetentionPolicyType implements RetentionPolicyType {\n\n        INSTANCE;\n\n        @Override\n        public String getName() {\n            return \"daily\";\n        }\n\n        @Override\n        public List<Parameter<?>> getParameters() {\n            return List.of(new Parameter<>(GRACE_PERIOD_DAYS, IntegerArgumentType.integer(0), Integer.class));\n        }\n\n        @Override\n        public RetentionPolicy createPolicy(final Map<String, String> config) {\n            int gracePeriodTemp = DEFAULT_GRACE_PERIOD_DAYS;\n            if (config != null && config.containsKey(GRACE_PERIOD_DAYS)) {\n                try {\n                    gracePeriodTemp = Integer.parseInt(config.get(GRACE_PERIOD_DAYS));\n                } catch (NumberFormatException nfe) {\n                    syslog().debug(\"Ignoring invalid grace period \" + config.get(GRACE_PERIOD_DAYS), nfe);\n                }\n            }\n            final int gracePeriod = gracePeriodTemp;\n\n            return new DailyRetentionPolicy(gracePeriod);\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.localized(L10N_KEY, \"<\" + GRACE_PERIOD_DAYS + \">\");\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/FixedCountRetentionPolicy.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport com.mojang.brigadier.arguments.IntegerArgumentType;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Retention policy that keeps only the n most-recent snapshots.\n *\n * @author pcal\n * @since 0.2.0\n */\nclass FixedCountRetentionPolicy implements RetentionPolicy {\n\n    private static final int COUNT_DEFAULT = 10;\n    private static final String POLICY_NAME = \"fixed\";\n    private static final String L10N_KEY = \"fastback.retain.fixed.description\";\n    private static final String COUNT_PARAM = \"count\";\n    private final int count;\n\n    public static FixedCountRetentionPolicy create(Map<String, String> config) {\n        int count = COUNT_DEFAULT;\n        if (config != null && config.containsKey(COUNT_PARAM)) {\n            try {\n                count = Integer.parseInt(config.get(COUNT_PARAM));\n            } catch (NumberFormatException nfe) {\n                syslog().debug(\"Ignoring invalided fixed count \" + config.get(COUNT_PARAM), nfe);\n            }\n        }\n        return new FixedCountRetentionPolicy(count);\n    }\n\n    private FixedCountRetentionPolicy(int count) {\n        this.count = count;\n    }\n\n    @Override\n    public UserMessage getDescription() {\n        return UserMessage.localized(L10N_KEY, this.count);\n    }\n\n    @Override\n    public Collection<SnapshotId> getSnapshotsToPrune(Set<SnapshotId> fromSnapshots) {\n        final List<SnapshotId> sorted = new ArrayList<>(fromSnapshots);\n        sorted.sort(Collections.reverseOrder());\n        if (sorted.size() > count) {\n            return sorted.subList(count - 1, sorted.size() - 1);\n        } else {\n            return Collections.emptySet();\n        }\n    }\n\n    enum Type implements RetentionPolicyType {\n\n        INSTANCE;\n\n        @Override\n        public String getName() {\n            return POLICY_NAME;\n        }\n\n        @Override\n        public List<Parameter<?>> getParameters() {\n            return List.of(new Parameter<>(COUNT_PARAM, IntegerArgumentType.integer(1), Integer.class));\n        }\n\n        @Override\n        public RetentionPolicy createPolicy(final Map<String, String> config) {\n            return create(config);\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.localized(L10N_KEY, \"<\" + COUNT_PARAM + \">\");\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/GFSRetentionPolicy.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.time.LocalDate;\nimport java.time.Period;\nimport java.time.temporal.ChronoField;\nimport java.time.temporal.IsoFields;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.TimeZone;\nimport java.util.function.Supplier;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Policy that implements a simple 'Grandfather-Father-Son' strategy.  It retains\n * - every backup in the last 24 hours\n * - the latest daily backup for the past week\n * - the latest weekly backup for the past month\n * - the latest monthly backup for all past months\n *\n * @author pcal\n * @since 0.9.0\n */\nclass GFSRetentionPolicy implements RetentionPolicy {\n\n    private static final String L10N_KEY = \"fastback.retain.gfs.description\";\n    Supplier<LocalDate> nowSupplier = () ->\n            LocalDate.now(TimeZone.getDefault().toZoneId());\n\n    public GFSRetentionPolicy() {\n    }\n\n    @Override\n    public UserMessage getDescription() {\n        return UserMessage.localized(L10N_KEY);\n    }\n\n    @Override\n    public Collection<SnapshotId> getSnapshotsToPrune(Set<SnapshotId> snapshots) {\n        final List<SnapshotId> toPrune = new ArrayList<>();\n        final LocalDate now = nowSupplier.get();\n        final LocalDate gracePeriodStart = now.minus(Period.ofDays(2));\n        final LocalDate oneWeekAgo = now.minus(Period.ofDays(7));\n        final LocalDate oneMonthAgo = now.minus(Period.ofDays(30));\n        Integer currentDay = null, currentWeek = null, currentMonth = null;\n        List<SnapshotId> sortedDesending = new ArrayList<>(snapshots);\n        Collections.sort(sortedDesending, Collections.reverseOrder());\n        for (final SnapshotId sid : sortedDesending) {\n            final LocalDate snapshotDate = sid.getDate().toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDate();\n            if (snapshotDate.isAfter(gracePeriodStart)) {\n                syslog().debug(\"Will retain \" + sid + \" because still in the grace period\");\n            } else if (snapshotDate.isAfter(oneWeekAgo)) {\n                final int snapshotDay = snapshotDate.get(ChronoField.DAY_OF_MONTH);\n                if (currentDay == null || currentDay != snapshotDay) {\n                    currentDay = snapshotDay;\n                } else {\n                    toPrune.add(sid);\n                }\n            } else if (snapshotDate.isAfter(oneMonthAgo)) {\n                final int snapshotWeek = snapshotDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);\n                if (currentWeek == null || currentWeek != snapshotWeek) {\n                    currentWeek = snapshotWeek;\n                } else {\n                    toPrune.add(sid);\n                }\n            } else {\n                final int snapshotMonth = snapshotDate.get(ChronoField.MONTH_OF_YEAR);\n                if (currentMonth == null || snapshotMonth != currentMonth) {\n                    currentMonth = snapshotMonth;\n                } else {\n                    toPrune.add(sid);\n                }\n            }\n        }\n        return toPrune;\n    }\n\n    /**\n     * Retention policy that keeps only the most-recent snapshot of each day.  Provides for a grace period\n     * during which all snapshots are retained.\n     *\n     * @author pcal\n     * @since 0.2.0\n     */\n    public enum GFSRetentionPolicyType implements RetentionPolicyType {\n\n        INSTANCE;\n\n        @Override\n        public String getName() {\n            return \"gfs\";\n        }\n\n        @Override\n        public List<Parameter<?>> getParameters() {\n            return Collections.emptyList();\n        }\n\n        @Override\n        public RetentionPolicy createPolicy(final Map<String, String> config) {\n            return new GFSRetentionPolicy();\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.localized(L10N_KEY);\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicy.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\n\nimport java.util.Collection;\nimport java.util.Set;\n\n\n/**\n * Encapsulates a policy choice about which snapshots should be kept when pruning.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic interface RetentionPolicy {\n\n    UserMessage getDescription();\n\n    Collection<SnapshotId> getSnapshotsToPrune(final Set<SnapshotId> fromSnapshots);\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicyCodec.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Singleton which can encode a RetentionPolicy into a single-line string that can easily be saved in git config.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic enum RetentionPolicyCodec {\n\n    INSTANCE;\n\n    public RetentionPolicy decodePolicy(final List<RetentionPolicyType> availablePolicyTypes,\n                                        final String encodedPolicyOriginal) {\n        requireNonNull(availablePolicyTypes);\n        requireNonNull(encodedPolicyOriginal);\n        final String encodedPolicy = encodedPolicyOriginal.trim();\n        int firstSpace = encodedPolicy.indexOf(' ');\n        final Map<String, String> config;\n        final String encodedTypeName;\n        if (firstSpace == -1) {\n            config = null;\n            encodedTypeName = encodedPolicy.trim();\n        } else {\n            encodedTypeName = encodedPolicy.substring(0, firstSpace).trim();\n            config = decodeMap(encodedPolicy.substring(firstSpace + 1));\n        }\n        for (final RetentionPolicyType rtp : availablePolicyTypes) {\n            if (rtp.getEncodedName().equals(encodedTypeName)) {\n                return rtp.createPolicy(config);\n            }\n        }\n        syslog().debug(\"Ignoring invalid retention policy \" + encodedPolicy);\n        return null;\n    }\n\n    public String encodePolicy(final RetentionPolicyType policyType, final Map<String, String> config) {\n        return policyType.getEncodedName() + \" \" + encodeMap(config);\n    }\n\n    // ====================================================================\n    // Package-private methods\n\n    static String encodeMap(Map<String, String> map) {\n        final StringBuilder out = new StringBuilder();\n        List<String> keys = new ArrayList<>(map.keySet());\n        Collections.sort(keys);\n        boolean isFirst = true;\n        for (final String key : keys) {\n            if (!isValidForEncode(key)) {\n                syslog().debug(\"Ignoring invalid key \" + key);\n                continue;\n            }\n            final String value = map.get(key);\n            if (!isValidForEncode(value)) {\n                syslog().debug(\"Ignoring invalid value \" + value);\n                continue;\n            }\n            if (!isFirst) {\n                out.append(' ');\n            } else {\n                isFirst = false;\n            }\n            out.append(key);\n            out.append('=');\n            out.append(value);\n\n        }\n        return out.toString();\n    }\n\n    static Map<String, String> decodeMap(String encodedMap) {\n        final Map<String, String> out = new HashMap<>();\n        final String[] tokens = encodedMap.split(\" \");\n        for (final String token : tokens) {\n            final String[] keyVal = token.split(\"=\");\n            if (keyVal.length != 2) {\n                syslog().debug(\"Ignoring invalid token \" + Arrays.toString(keyVal));\n                continue;\n            }\n            out.put(keyVal[0].trim(), keyVal[1].trim());\n        }\n        return out;\n    }\n\n    private static boolean isValidForEncode(String keyOrVal) {\n        return !(keyOrVal.contains(\"=\") || keyOrVal.contains(\" \"));\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicyType.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport com.mojang.brigadier.arguments.ArgumentType;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.retention.GFSRetentionPolicy.GFSRetentionPolicyType;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * Encapsulates a general kind of retention policy.  Takes simple user-supplied configuration and produces a\n * RetentionPolicy.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic interface RetentionPolicyType {\n\n    static List<RetentionPolicyType> getAvailable() {\n        return List.of(\n                DailyRetentionPolicy.DailyRetentionPolicyType.INSTANCE,\n                FixedCountRetentionPolicy.Type.INSTANCE,\n                GFSRetentionPolicyType.INSTANCE,\n                AllRetentionPolicy.Type.INSTANCE);\n    }\n\n    record Parameter<V>(String name, ArgumentType<V> type, Class<V> clazz) {\n    }\n\n    String getName();\n\n    List<Parameter<?>> getParameters();\n\n    RetentionPolicy createPolicy(Map<String, String> config);\n\n    default String getEncodedName() {\n        return getName();\n    }\n\n    default String getCommandName() {\n        return getName();\n    }\n\n    UserMessage getDescription();\n\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/EnvironmentUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.utils;\n\nimport net.minecraft.network.chat.Component;\nimport net.pcal.fastback.common.config.GitConfig;\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING;\nimport static net.pcal.fastback.common.logging.UserMessage.localized;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\nimport static net.pcal.fastback.common.utils.ProcessUtils.doExec;\n\npublic class EnvironmentUtils {\n\n    public static String getGitVersion() {\n        return execForVersion(new String[]{\"git\", \"--version\"});\n    }\n\n    public static String getGitLfsVersion() {\n        return execForVersion(new String[]{\"git\", \"lfs\", \"--version\"});\n    }\n\n    /**\n     * @return true if native git is installed correctly, or if this is a legacy jgit-based backup.\n     */\n    public static boolean isNativeOk(final GitConfig conf, final UserLogger ulog, final boolean verbose) {\n        return isNativeOk(conf.getBoolean(IS_NATIVE_GIT_ENABLED), ulog, verbose);\n    }\n\n    /**\n     * @return true if native git is installed correctly, or if this is a legacy jgit-based backup.\n     */\n    public static boolean isNativeOk(boolean isNativeGitEnabled, UserLogger ulog, boolean verbose) {\n        if (isNativeGitEnabled) { // default is true; false is undocumented and deprecated\n            final Component notInstalled = Component.translatable(\"fastback.values.not-installed\");\n            final String gitVersion = getGitVersion();\n            final String gitLfsVersion = getGitLfsVersion();\n            if (verbose) {\n                ulog.message(localized(\"fastback.chat.native-info\",\n                        gitVersion != null ? gitVersion : notInstalled,\n                        gitLfsVersion != null ? gitLfsVersion : notInstalled));\n            }\n            if (gitVersion == null) {\n                ulog.message(styledLocalized(\"fastback.chat.native-git-not-installed\", ERROR, System.getenv(\"PATH\")));\n                return false;\n            }\n            if (gitLfsVersion == null) {\n                ulog.message(styledLocalized(\"fastback.chat.native-lfs-not-installed\", ERROR, System.getenv(\"PATH\")));\n                return false;\n            }\n        } else {\n            if (verbose) ulog.message(styledLocalized(\"fastback.chat.native-disabled\", WARNING));\n        }\n        return true;\n    }\n\n    private static String execForVersion(String[] cmd) {\n        final List<String> stdout = new ArrayList<>();\n        final int exit;\n        try {\n            exit = doExec(cmd, Collections.emptyMap(), stdout::add, line -> {\n            });\n        } catch (ProcessException e) {\n            syslog().debug(\"Could not run \" + String.join(\" \", cmd), e);\n            return null;\n        }\n        return exit == 0 ? stdout.get(0) : null;\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/Executor.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.utils;\n\nimport net.pcal.fastback.common.logging.UserLogger;\n\n/**\n * Thin, singleton wrapper around an ExecutorService.  Use this to do things in separate threads.\n *\n * @author pcal\n * @since 0.2.0\n */\npublic interface Executor {\n\n    static Executor executor() {\n        return Singleton.INSTANCE;\n    }\n\n    // TODO kill UserLogger param and throw Blocking exception instead\n    void execute(final ExecutionLock lock, final UserLogger ulog, final Runnable runnable);\n\n    int getActiveCount();\n\n    void start();\n\n    void stop();\n\n    enum ExecutionLock {\n        NONE,\n        WRITE_CONFIG,\n        WRITE,\n    }\n\n    class Singleton {\n        private static final Executor INSTANCE = new ExecutorImpl();\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/ExecutorImpl.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.utils;\n\nimport net.pcal.fastback.common.logging.UserLogger;\n\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.ThreadPoolExecutor;\nimport java.util.concurrent.TimeUnit;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\nimport static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR;\nimport static net.pcal.fastback.common.logging.UserMessage.styledLocalized;\n\n/**\n * @author pcal\n * @since 0.2.0\n */\nclass ExecutorImpl implements Executor {\n\n    private ThreadPoolExecutor executor = null;\n\n    private Future<?> exclusiveFuture = null;\n\n    @Override\n    public void execute(ExecutionLock lock, UserLogger ulog, Runnable runnable) {\n        requireNonNull(lock, \"lock\");\n        if (this.executor == null) throw new IllegalStateException(\"Executor not started\");\n        switch (lock) {\n            case NONE:\n            case WRITE_CONFIG: // revisit this\n                this.executor.submit(runnable);\n                break;\n            case WRITE:\n                if (this.exclusiveFuture != null && !this.exclusiveFuture.isDone()) {\n                    ulog.message(styledLocalized(\"fastback.chat.thread-busy\", ERROR));\n                } else {\n                    syslog().debug(\"executing \" + runnable);\n                    this.exclusiveFuture = this.executor.submit(runnable);\n                }\n                break;\n            default:\n                throw new IllegalStateException();\n        }\n    }\n\n    @Override\n    public int getActiveCount() {\n        return this.executor.getActiveCount();\n    }\n\n    @Override\n    public void start() {\n        this.executor = new ThreadPoolExecutor(0, 3, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());\n    }\n\n    @Override\n    public void stop() {\n        shutdownExecutor(this.executor);\n        this.executor = null;\n    }\n\n    /**\n     * Lifted straight from the docs:\n     * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html\n     */\n    private static void shutdownExecutor(final ExecutorService pool) {\n        pool.shutdown(); // Disable new tasks from being submitted\n        try {\n            // Wait a while for existing tasks to terminate\n            if (!pool.awaitTermination(5, TimeUnit.MINUTES)) {\n                pool.shutdownNow(); // Cancel currently executing tasks\n                // Wait a while for tasks to respond to being cancelled\n                if (!pool.awaitTermination(5, TimeUnit.MINUTES))\n                    System.err.println(\"Pool did not terminate\");\n            }\n        } catch (InterruptedException ie) {\n            // (Re-)Cancel if current thread also interrupted\n            pool.shutdownNow();\n            // Preserve interrupt status\n            Thread.currentThread().interrupt();\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/FileUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.utils;\n\nimport java.io.File;\nimport java.io.FileNotFoundException;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\npublic class FileUtils {\n\n    public static void mkdirs(final Path path) throws IOException {\n        final File file = path.toFile();\n        if (file.exists()) {\n            if (!file.isDirectory()) {\n                throw new IOException(\"Cannot create directory because file exists at \" + path);\n            }\n        } else {\n            file.mkdirs();\n            if (!file.exists() || !file.isDirectory()) {\n                throw new IOException(\"Failed to create directory at \" + path);\n            }\n        }\n    }\n\n    public static void rmdir(final Path path) throws IOException {\n        org.apache.commons.io.FileUtils.deleteDirectory(path.toFile());\n    }\n\n    public static void writeResourceToFile(String resourcePath, Path targetFile) throws IOException {\n        final String rawResource;\n        try (InputStream in = FileUtils.class.getClassLoader().getResourceAsStream(resourcePath)) {\n            if (in == null) {\n                throw new FileNotFoundException(\"Unable to load resource \" + resourcePath); // wat\n            }\n            rawResource = new String(in.readAllBytes(), StandardCharsets.UTF_8);\n        }\n        mkdirs(targetFile.getParent());\n        Files.writeString(targetFile, rawResource);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/ProcessException.java",
    "content": "package net.pcal.fastback.common.utils;\n\nimport java.util.List;\nimport java.util.function.Consumer;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * Thrown when an attempt to execute an external process fails.\n *\n * @author pcal\n * @since 0.15.0\n */\npublic class ProcessException extends Exception {\n    private final List<String> processOutput;\n\n    ProcessException(String[] args, final int exitCode, final List<String> processOutput, Throwable nested) {\n        super(\"Exit \" + exitCode + \" when executing: \" + String.join(\" \", args), nested);\n        this.processOutput = requireNonNull(processOutput);\n    }\n\n    ProcessException(String[] args, final int exitCode, final List<String> stdoutLines) {\n        super(\"Exit \" + exitCode + \" when executing: \" + String.join(\" \", args));\n        this.processOutput = requireNonNull(stdoutLines);\n    }\n\n    /**\n     * Copies the original process to the given line consumer.\n     */\n    public void writeProcessOutput(Consumer<String> consumer) {\n        for (String line : processOutput) consumer.accept(line);\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/net/pcal/fastback/common/utils/ProcessUtils.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.utils;\n\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.Reader;\nimport java.io.Writer;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\nimport static java.util.Objects.requireNonNull;\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n\n/**\n * Utilities for executing other processes (git, mainly).\n *\n * @author pcal\n * @since 0.15.0\n */\npublic class ProcessUtils {\n\n    public static int doExec(String[] args, final Map<String, String> envOriginal, Consumer<String> stdoutSink, Consumer<String> stderrSink) throws ProcessException {\n        return doExec(args, envOriginal, stdoutSink, stderrSink, true);\n    }\n\n    public static int doExec(final String[] args, final Map<String, String> envOriginal, final Consumer<String> stdoutSink, final Consumer<String> stderrSink, boolean throwOnNonZero) throws ProcessException {\n        syslog().debug(\"Executing \" + String.join(\" \", args));\n        final ProcessBuilder pb = new ProcessBuilder(args);\n        final Map<String, String> env = pb.environment();\n        // Output a few values that are important for debugging; don't indiscriminately dump everything or someone's going\n        // to end up uploading a bunch of passwords into pastebin.\n        syslog().debug(\"PATH: \" + env.get(\"PATH\"));\n        if (System.getProperty(\"os.name\").toLowerCase().contains(\"windows\")) {\n            syslog().debug(\"HOME: \" + env.get(\"USERPROFILE\"));\n            syslog().debug(\"USER: \" + env.get(\"USERNAME\"));\n        } else {\n            syslog().debug(\"HOME: \" + env.get(\"HOME\"));\n            syslog().debug(\"USER: \" + env.get(\"USER\"));\n        }\n\n        final List<String> errorBuffer = new ArrayList<>();\n        final Consumer<String> stdout = line -> {\n            syslog().debug(\"[STDOUT] \" + line);\n            stdoutSink.accept(line);\n            errorBuffer.add(\"[STDOUT] \" + line);\n        };\n        final Consumer<String> stderr = line -> {\n            syslog().debug(\"[STDERR] \" + line);\n            stderrSink.accept(line);\n            errorBuffer.add(\"[STDERR] \" + line);\n        };\n        final int exit;\n        try {\n            final Process p = pb.start();\n            exit = drainAndWait(p, new LineWriter(stdout), new LineWriter(stderr));\n        } catch (IOException | InterruptedException e) {\n            throw new ProcessException(args, 0, errorBuffer, e);\n        }\n        if (throwOnNonZero && exit != 0) {\n            throw new ProcessException(args, exit, errorBuffer);\n        }\n        return exit;\n    }\n\n    // ======================================================================\n    // Private\n\n    private static class LineWriter extends Writer {\n\n        private final Consumer<String> sink;\n        private final StringBuilder buffer = new StringBuilder();\n\n        private LineWriter(final Consumer<String> sink) {\n            this.sink = requireNonNull(sink);\n        }\n\n        @Override\n        public void write(char[] cbuf, int off, int len) {\n            buffer.append(cbuf, off, len);\n            outputLines();\n        }\n\n        private void outputLines() {\n            int lineStart = 0, lineEnd;\n            while ((lineEnd = findLineEnd(buffer, lineStart)) != -1) {\n                final String line = buffer.substring(lineStart, lineEnd).trim();\n                if (line.length() > 0) this.sink.accept(line);\n                lineStart = lineEnd + 1;\n            }\n            if (lineStart != 0) {\n                buffer.delete(0, lineStart);\n            }\n        }\n\n        private static int findLineEnd(StringBuilder buffer, int lineStart) {\n            int newLine = buffer.indexOf(\"\\n\", lineStart);\n            int carriage = buffer.indexOf(\"\\r\", lineStart);\n            if (newLine == -1) return carriage;\n            if (carriage == -1) return newLine;\n            return Math.min(newLine, carriage);\n        }\n\n        @Override\n        public void flush() {\n            outputLines();\n            if (buffer.length() > 0) {\n                this.sink.accept(buffer.toString());\n            }\n        }\n\n        @Override\n        public void close() {\n        }\n    }\n\n    private static int drainAndWait(Process process, Writer stdoutSink, Writer stderrSink) throws IOException, InterruptedException {\n\n        Reader stdoutReader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8);\n        Reader stderrReader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8);\n\n        char[] buffer = new char[1024];\n\n        while (true) {\n            boolean readAny = false;\n            //\n            // process stdin\n            //\n            if (stdoutReader != null && stdoutReader.ready()) {\n                int read = stdoutReader.read(buffer, 0, buffer.length);\n                if (read < 0) {\n                    stdoutReader = null;\n                } else if (read > 0) {\n                    readAny = true;\n                    stdoutSink.write(buffer, 0, read);\n                }\n            }\n            //\n            // process stdout\n            //\n            if (stderrReader != null && stderrReader.ready()) {\n                int read = stderrReader.read(buffer, 0, buffer.length);\n                if (read < 0) {\n                    stderrReader = null;\n                } else if (read > 0) {\n                    readAny = true;\n                    stderrSink.write(buffer, 0, read);\n                }\n            }\n\n            if (readAny) {\n                continue;\n            } else if (!process.isAlive()) {\n                return process.exitValue();\n            } else {\n                try {\n                    Thread.sleep(10); // FIXME add timeout?\n                } catch (InterruptedException ie) {\n                    process.destroy();\n                    throw ie;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/de_de.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"Erstellt ein Remote-Backup-Ziel im Dateisystem.\",\n  \"fastback.help.command.delete\"                 : \"Löscht einen individuellen Snapshot.\",\n  \"fastback.help.command.disable\"                : \"Deaktiviert Backups für diese Welt.\",\n  \"fastback.help.command.enable\"                 : \"Aktiviert lokale Backups für diese Welt.\",\n  \"fastback.help.command.full\"                   : \"Führt sofort ein lokales und remotes Backup aus.\",\n  \"fastback.help.command.gc\"                     : \"Führt eine Garbage Collection aus um Festplattenspeicher freizugeben.\",\n  \"fastback.help.command.help\"                   : \"Zeigt Hilfe zu Befehlen an.\",\n  \"fastback.help.command.info\"                   : \"Informationen zum aktuellen Status und den Einstellungen des aktuellen Backups.\",\n  \"fastback.help.command.init\"                   : \"Initialisiert FastBack für die aktuelle Welt. Dieser Befehl muss als erstes ausgeführt werden.\",\n  \"fastback.help.command.list\"                   : \"Zeigt eine Liste aller Snapshots dieser Welt.\",\n  \"fastback.help.command.local\"                  : \"Erstellt sofort ein lokales Backup.\",\n  \"fastback.help.command.prune\"                  : \"Löscht alte Snapshots anhand der Aufbewahrungsrichtlinie.\",\n  \"fastback.help.command.push\"                   : \"Überträgt ein Snapshot an das Remote-Backup-Ziel.\",\n  \"fastback.help.command.remote-delete\"          : \"Löscht ein Snapshot im Remote-Backup-Ziel.\",\n  \"fastback.help.command.remote-list\"            : \"Remote Snapshots auflisten.\",\n  \"fastback.help.command.remote-prune\"           : \"Löscht alte Snapshots im Remote-Backup-Ziel anhand der Aufbewahrungsrichtlinie.\",\n  \"fastback.help.command.remote-restore\"         : \"Stellt ein Remote Snapshot wieder her.\",\n  \"fastback.help.command.restore\"                : \"Stellt ein Backup Snapshot wieder her.\",\n  \"fastback.help.command.set\"                    : \"Ändert Einstellungsparameter.\",\n  \"fastback.help.command.set-autoback-action\"    : \"Legt die Aktion fest, die bei automatischen Backups ausgeführt wird.\",\n  \"fastback.help.command.set-autoback-wait\"      : \"Legt die Mindestdauer zwischen Backups in Minuten fest.\",\n  \"fastback.help.command.set-remote\"             : \"Legt die URL für Remote-Backups fest.\",\n  \"fastback.help.command.set-remote-retention\"   : \"Legt die Aufbewahrungsrichtlinie für Remote-Backups fest.\",\n  \"fastback.help.command.set-retention\"          : \"Legt die Aufbewahrungsrichtlinie fest.\",\n  \"fastback.help.command.set-shutdown-action\"    : \"Legt die Aktion beim Herunterfahren fest.\",\n  \"fastback.help.subcommands\"                    : \"Verfügbare Unterbefehle:\\n%s\\nFühre\\n/backup help [Unterbefehl]\\naus, um zu einem Befehl Hilfe zu erhalten oder gehe auf https://pcal43.github.io/fastback\",\n  \"fastback.help.suggest-init\"                   : \"\\nUm zu starten, führe '/backup init' aus\",\n  \"fastback.help.backup-start\"                   : \"Backup gestartet für %s\",\n  \"fastback.chat.backup-complete\"                : \"Backup abgeschlossen.\",\n  \"fastback.chat.backup-complete-elapsed\"        : \"Backup abgeschlossen. Benötigte Zeit: %s\",\n  \"fastback.chat.create-file-remote-created\"     : \"Git-Repository erstellt unter %s\\nRemote-Backups aktiviert unter:\\n%s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"Verzeichnis existiert bereits:\\n%s\",\n  \"fastback.chat.delete-start\"                   : \"Lösche Snapshot %s %s\",\n  \"fastback.chat.delete-done\"                    : \"Snapshot %s gelöscht\",\n  \"fastback.chat.disable-already-disabled\"       : \"Backups sind bereits deaktiviert\",\n  \"fastback.chat.gc-done\"                        : \"Garbage Collection abgeschlossen.  %s freigegeben.\",\n  \"fastback.chat.gc-done-no-reclaim\"             : \"Garbage Collection abgeschlossen.\",\n  \"fastback.chat.gc-failed\"                      : \"Garbage collection fehlgeschlagen. Details können dem Log entnommen werden.\",\n  \"fastback.chat.commit-failed\"                  : \"Backup fehlgeschlagen. Details können dem Log entnommen werden.\",\n  \"fastback.chat.commit-start\"                   : \"Erstelle Backup Snapshot %s\",\n  \"fastback.chat.commit-complete\"                : \"Commit abgeschlossen.\",\n  \"fastback.chat.info-autoback-action\"           : \"Autoback Aktion: %s\",\n  \"fastback.chat.info-autoback-wait\"             : \"Autoback Wartezeit: %s Minuten\",\n  \"fastback.chat.info-backup-size\"               : \"Lokale Backup Größe : %s\",\n  \"fastback.chat.info-fastback-version\"          : \"FastBack Version: %s\",\n  \"fastback.chat.info-header\"                    : \"\\nFastBack Info\\n-------------\",\n  \"fastback.chat.info-local-disabled\"            : \"Lokale Backups: deaktiviert\",\n  \"fastback.chat.info-local-enabled\"             : \"Lokale Backups: aktiviert\",\n  \"fastback.chat.info-remote-url\"                : \"Remote URL: %s\",\n  \"fastback.chat.info-shutdown-action\"           : \"Aktion beim Herunterfahren: %s\",\n  \"fastback.chat.info-uuid\"                      : \"Backup UUID: %s\",\n  \"fastback.chat.info-world-size\"                : \"Weltgröße: %s\",\n  \"fastback.chat.internal-error\"                 : \"Während des Backups ist ein unerwarteter Fehler aufgetreten. Details können dem Log entnommen werden.\",\n  \"fastback.chat.invalid-input\"                  : \"Ungültige Eingabe: %s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"Lokale Snapshots:\",\n  \"fastback.chat.lockfile-exists\"                : \"Ein Backup Lock-File existiert. %s\",\n  \"fastback.chat.lockfile-cleanup-enabled\"       : \"%s, versuche Backup-Lockfiles zu bereinigen...\",\n  \"fastback.chat.missing-argument\"               : \"Fehlendes Argument: %s\",\n  \"fastback.chat.no-change\"                      : \"Keine Änderung.\",\n  \"fastback.chat.enabled\"                        : \"Für diese Welt sind Backups bereits aktiviert.\",\n  \"fastback.chat.native-disabled\"                : \"Natives Git ist deaktiviert. FastBack wird JGit für Backups verwenden.\",\n  \"fastback.chat.native-info\"                    : \"git: %s\\ngit-lfs: %s\",\n  \"fastback.chat.native-git-not-installed\"       : \"git konnte nicht im PATH gefunden werden:\\n%s\\n\\nBackups können nicht durchgeführt werden, bis git installiert ist.\\nSiehe https://pcal43.github.io/fastback/native-git.html für mehr Informationen.\",\n  \"fastback.chat.native-lfs-not-installed\"       : \"git-lfs konnte nicht im PATH gefunden werden:\\n%s\\n\\nBackups können nicht durchgeführt werden, bis git-lfs installiert ist.\\nSiehe https://pcal43.github.io/fastback/native-git.html für mehr Informationen.\",\n  \"fastback.chat.not-enabled\"                    : \"Backups sind für diese Welt nicht aktiviert. Führe '/backup init' aus\",\n  \"fastback.chat.ok\"                             : \"OK\",\n  \"fastback.chat.prune-done\"                     : \"%s Snapshots bereinigt.\",\n  \"fastback.chat.prune-no-default\"               : \"Es ist keine Standard-Aufbewahrungsrichtlinie konfiguriert. Bitte '/backup set retention-policy' ausführen\",\n  \"fastback.chat.prune-suggest-gc\"               : \"Führe '/backup gc' aus um Festplattenspeicher freizugeben.\",\n  \"fastback.chat.push-failed\"                    : \"Das lokale Backup war erfolgreich, aber das Remote-Backup ist fehlgeschlagen. Details können dem Log entnommen werden.\",\n  \"fastback.chat.push-started\"                   : \"Lade Backup hoch zu %s...\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"Remote-Ziel %s ist das Backup-Ziel für einen andere Welt.\\nBitte konfiguriere ein neues Remote-Backup-Ziel für diese Welt.\",\n  \"fastback.chat.push-done\"                      : \"Backup hochgeladen zu %s\",\n  \"fastback.chat.push-done-elapsed\"              : \"Backup hochgeladen zu %s. Benötigte Zeit: %s\",\n  \"fastback.chat.remote-delete-done\"             : \"Remote Snapshot %s wurde gelöscht\",\n  \"fastback.chat.remote-enabled\"                 : \"Remote Backups wurden aktiviert mit Ziel:\\n%s\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"Führe '/backup set remote-url <Remote-URL>' aus um Remote-Backups zu aktivieren.\",\n  \"fastback.chat.remote-list-done\"               : \"%d Snapshots gefunden in %s\",\n  \"fastback.chat.remote-no-url\"                  : \"Es ist kein Remote-URL konfiguriert.\\nFühre '/backup set remote-url <Remote-URL>' aus\",\n  \"fastback.chat.remote-set\"                     : \"Remote-Backup-URL gesetzt auf:\\n%s\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"Kein Remote-Snapshot-Aufbewahrungsrichtlinie konfiguriert.\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"Kein Remote-Aufbewahrungsrichtlinie konfiguriert. Führe '/backup set remote-retention-policy' aus\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"Remote-Snapshot-Aufbewahrungsrichtlinie gesetzt auf:\",\n  \"fastback.chat.restore-done\"                   : \"Snapshot wiederhergestellt zu \\n%s\",\n  \"fastback.chat.restore-nosuch\"                 : \"Snapshot %s wurde nicht gefunden\",\n  \"fastback.chat.retention-policy-none\"          : \"Keine Snapshot Aufbewahrungsrichtlinie konfiguriert.\",\n  \"fastback.chat.retention-policy-not-set\"       : \"Keine Aufbewahrungsrichtlinie konfiguriert. Führe '/backup set retention-policy' aus\",\n  \"fastback.chat.retention-policy-set\"           : \"Snapshot Aufbewahrungsrichtlinie gesetzt auf:\",\n  \"fastback.chat.thread-busy\"                    : \"Ein anderes Backup läuft gerade. Bitte warte, bis das andere Backup abgeschlossen ist und versuche es dann erneut.\",\n  \"fastback.chat.thread-waiting\"                 : \"Warte, bis aktuelles Backup abgeschlossen ist...\",\n  \"fastback.chat.world-save\"                     : \"Speichere Welt bevor das Backup gestartet wird...\",\n  \"fastback.hud.local-saving\"                    : \"Sichere lokales Backup...\",\n  \"fastback.hud.prune-started\"                   : \"Optimiere (pruning)...\",\n  \"fastback.message.backing-up\"                  : \"Backup wird erstellt...\",\n  \"fastback.broadcast.message\"                   : \"Der Server startet ein Backup.\",\n  \"fastback.retain.all.description\"              : \"Behalte alle Snapshots, keine Bereinigung.\",\n  \"fastback.retain.fixed.description\"            : \"Fixed: Behalte nur die %s neuesten Snapshots.\",\n  \"fastback.retain.daily.description\"            : \"Daily: Behalte das neueste Snapshot für jeden Tag und alle Snapshots der letzten %s Tage\",\n  \"fastback.retain.gfs.description\"              : \"GFS: Behalte alle heutigen Backups + das neueste tägliche Backup für die letzten Woche + das neueste wöchentliche Backup des letzten Monats + das neueste Backup von jedem Monat\",\n  \"fastback.values.disabled\"                     : \"deaktiviert\",\n  \"fastback.values.enabled\"                      : \"aktiviert\",\n  \"fastback.values.none\"                         : \"keine\",\n  \"fastback.values.not-installed\"                : \"nicht installiert\"\n}\n"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/en_us.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"Create a remote backup target on the file system.\",\n  \"fastback.help.command.delete\"                 : \"Delete an individual snapshot.\",\n  \"fastback.help.command.disable\"                : \"Disable backups on this world.\",\n  \"fastback.help.command.enable\"                 : \"Enable local backups backups on this world.\",\n  \"fastback.help.command.full\"                   : \"Perform a local and remote backup immediately.\",\n  \"fastback.help.command.gc\"                     : \"Run garbage collection to free up disk space.\",\n  \"fastback.help.command.help\"                   : \"Get help on commands.\",\n  \"fastback.help.command.info\"                   : \"Info about current backup state and settings.\",\n  \"fastback.help.command.init\"                   : \"Initialize fastback for the current world. Run this first.\",\n  \"fastback.help.command.list\"                   : \"List backup snapshots for this world.\",\n  \"fastback.help.command.local\"                  : \"Perform a local backup immediately.\",\n  \"fastback.help.command.prune\"                  : \"Delete old snapshots according to the retention policy.\",\n  \"fastback.help.command.push\"                   : \"Push a snapshot to the remote.\",\n  \"fastback.help.command.remote-delete\"          : \"Delete a remote snapshot.\",\n  \"fastback.help.command.remote-list\"            : \"List remote snapshots.\",\n  \"fastback.help.command.remote-prune\"           : \"Delete old snapshots from the remote backup according to the remote retention policy.\",\n  \"fastback.help.command.remote-restore\"         : \"Restore a remote snapshot.\",\n  \"fastback.help.command.restore\"                : \"Restore a backup snapshot.\",\n  \"fastback.help.command.set\"                    : \"Change configuration settings.\",\n  \"fastback.help.command.set-autoback-action\"    : \"Set an action to perform during auto-backups.\",\n  \"fastback.help.command.set-autoback-wait\"      : \"Set the minimum number of minutes to wait between auto-backups.\",\n  \"fastback.help.command.set-remote\"             : \"Set the url for remote backups.\",\n  \"fastback.help.command.set-remote-retention\"   : \"Set snapshot retention policy for the remote backup.\",\n  \"fastback.help.command.set-retention\"          : \"Set snapshot retention policy.\",\n  \"fastback.help.command.set-shutdown-action\"    : \"Set an action to perform on shutdown.\",\n  \"fastback.help.subcommands\"                    : \"Available subcommands:\\n%s\\nFor detailed help on a subcommand, run\\n/backup help [subcommand]\\nor go to https://pcal43.github.io/fastback\",\n  \"fastback.help.suggest-init\"                   : \"\\nTo get started, type '/backup init'\",\n  \"fastback.help.backup-start\"                   : \"Backing up %s\",\n  \"fastback.chat.backup-complete\"                : \"Backup complete.\",\n  \"fastback.chat.backup-complete-elapsed\"        : \"Backup complete.  Time elapsed: %s\",\n  \"fastback.chat.create-file-remote-created\"     : \"Git repository created at %s\\nRemote backups enabled to:\\n%s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"Directory already exists:\\n%s\",\n  \"fastback.chat.delete-start\"                   : \"Deleting snapshot %s %s\",\n  \"fastback.chat.delete-done\"                    : \"Deleted snapshot %s\",\n  \"fastback.chat.disable-already-disabled\"       : \"Backups already disabled.\",\n  \"fastback.chat.gc-done\"                        : \"Garbage collection complete.  %s reclaimed.\",\n  \"fastback.chat.gc-done-no-reclaim\"             : \"Garbage collection complete.\",\n  \"fastback.chat.gc-failed\"                      : \"Garbage collection failed.  See log for details.\",\n  \"fastback.chat.commit-failed\"                  : \"Backup failed.  See log for details.\",\n  \"fastback.chat.commit-start\"                   : \"Creating backup snapshot %s\",\n  \"fastback.chat.commit-complete\"                : \"Commit Complete.\",\n  \"fastback.chat.info-autoback-action\"           : \"Autoback action: %s\",\n  \"fastback.chat.info-autoback-wait\"             : \"Autoback wait: %s minutes\",\n  \"fastback.chat.info-backup-size\"               : \"Local backup size: %s\",\n  \"fastback.chat.info-fastback-version\"          : \"FastBack version: %s\",\n  \"fastback.chat.info-header\"                    : \"\\nFastBack Info\\n-------------\",\n  \"fastback.chat.info-local-disabled\"            : \"Local backup: disabled\",\n  \"fastback.chat.info-local-enabled\"             : \"Local backup: enabled\",\n  \"fastback.chat.info-remote-url\"                : \"Remote URL: %s\",\n  \"fastback.chat.info-shutdown-action\"           : \"Shutdown action: %s\",\n  \"fastback.chat.info-uuid\"                      : \"Backup UUID: %s\",\n  \"fastback.chat.info-world-size\"                : \"World size: %s\",\n  \"fastback.chat.internal-error\"                 : \"An unexpected backup error occurred. See log for details.\",\n  \"fastback.chat.invalid-input\"                  : \"Invalid input: %s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"Local snapshots:\",\n  \"fastback.chat.lockfile-exists\"                : \"Backup lockfile exists. %s\",\n  \"fastback.chat.lockfile-cleanup-enabled\"       : \"%s, Attempting to clean up lockfile...\",\n  \"fastback.chat.missing-argument\"               : \"Missing argument: %s\",\n  \"fastback.chat.no-change\"                      : \"No change.\",\n  \"fastback.chat.enabled\"                        : \"Backups are already enabled on this world.\",\n  \"fastback.chat.native-disabled\"                : \"Native Git is disabled.  FastBack will use JGit for backups.\",\n  \"fastback.chat.native-info\"                    : \"git: %s\\ngit-lfs: %s\",\n  \"fastback.chat.native-git-not-installed\"       : \"git could not be located on your PATH:\\n%s\\n\\nBackups cannot be performed until you install git.\\nPlease see https://pcal43.github.io/fastback/native-git.html for more information.\",\n  \"fastback.chat.native-lfs-not-installed\"       : \"git-lfs could not be located on your PATH:\\n%s\\n\\nBackups cannot be performed until you install git-lfs.\\nPlease see https://pcal43.github.io/fastback/native-git.html for more information.\",\n  \"fastback.chat.not-enabled\"                    : \"Backups are not enabled on this world.  Run '/backup init'\",\n  \"fastback.chat.ok\"                             : \"ok\",\n  \"fastback.chat.prune-done\"                     : \"Pruned %s snapshots.\",\n  \"fastback.chat.prune-no-default\"               : \"No default pruning policy configured.  Please run /backup set retention-policy\",\n  \"fastback.chat.prune-suggest-gc\"               : \"Run /backup gc to reclaim disk space.\",\n  \"fastback.chat.push-failed\"                    : \"Local backup succeeded but remote backup failed.  See log for details.\",\n  \"fastback.chat.push-started\"                   : \"Uploading backup to %s...\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"Remote at %s is a backup target for a different world.\\nPlease configure a new remote for backing up this world.\",\n  \"fastback.chat.push-done\"                      : \"Backup uploaded to %s\",\n  \"fastback.chat.push-done-elapsed\"              : \"Backup uploaded to %s.  Time elapsed: %s\",\n  \"fastback.chat.remote-delete-done\"             : \"Deleted remote snapshot %s\",\n  \"fastback.chat.remote-enabled\"                 : \"Enabled remote backups to:\\n%s\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"Run '/backup set remote-url <remote-url>' to enable remote backups.\",\n  \"fastback.chat.remote-list-done\"               : \"%d snapshots found at %s\",\n  \"fastback.chat.remote-no-url\"                  : \"No remote URL is set.\\nRun '/backup set remote-url <remote-url>'\",\n  \"fastback.chat.remote-set\"                     : \"Remote backup URL set to:\\n%s\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"No remote snapshot retention policy set.\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"No remote retention policy set.  Run /backup set remote-retention-policy\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"Remote snapshot retention policy set to:\",\n  \"fastback.chat.restore-done\"                   : \"Snapshot restored to \\n%s\",\n  \"fastback.chat.restore-nosuch\"                 : \"No such snapshot %s\",\n  \"fastback.chat.retention-policy-none\"          : \"No snapshot retention policy set.\",\n  \"fastback.chat.retention-policy-not-set\"       : \"No retention policy set.  Run /backup set retention-policy\",\n  \"fastback.chat.retention-policy-set\"           : \"Snapshot retention policy set to:\",\n  \"fastback.chat.thread-busy\"                    : \"Another backup task is currently running.  Please wait for it to finish and try again.\",\n  \"fastback.chat.thread-waiting\"                 : \"Waiting for current backup tasks to complete...\",\n  \"fastback.chat.world-save\"                     : \"Saving world before backup...\",\n  \"fastback.hud.local-saving\"                    : \"Saving local backup...\",\n  \"fastback.hud.prune-started\"                   : \"Pruning...\",\n  \"fastback.message.backing-up\"                  : \"Backing up...\",\n  \"fastback.broadcast.message\"                   : \"The server is starting a backup.\",\n  \"fastback.retain.all.description\"              : \"Retain all snapshots; never prune.\",\n  \"fastback.retain.fixed.description\"            : \"Fixed: Keep only the %s most-recent snapshots.\",\n  \"fastback.retain.daily.description\"            : \"Daily: Keep the last snapshot from each day, plus all snapshots from the last %s days\",\n  \"fastback.retain.gfs.description\"              : \"GFS: Keep every backup today + latest daily backup in the last week + latest weekly backup in the last month + latest backup of each month\",\n  \"fastback.values.disabled\"                     : \"disabled\",\n  \"fastback.values.enabled\"                      : \"enabled\",\n  \"fastback.values.none\"                         : \"none\",\n  \"fastback.values.not-installed\"                : \"not installed\"\n}\n"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/es_es.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"Crea un repositorio de Git para las copias de seguridad en el sistema.\",\n  \"fastback.help.command.delete\"                 : \"Elimina una instantánea individual.\",\n  \"fastback.help.command.disable\"                : \"Desactiva las copias de seguridad en este mundo.\",\n  \"fastback.help.command.enable\"                 : \"Activa las copias de seguridad locales en este mundo.\",\n  \"fastback.help.command.full\"                   : \"Reliza una copia de seguridad (local y remota) inmediatamente.\",\n  \"fastback.help.command.gc\"                     : \"Limpia archivos innecesarios para liberar espacio en el disco.\",\n  \"fastback.help.command.help\"                   : \"Muestra ayuda sobre los comandos.\",\n  \"fastback.help.command.info\"                   : \"Información sobre los ajustes y estado actual de las copias de seguridad.\",\n  \"fastback.help.command.list\"                   : \"Enumera las instantáneas de este mundo.\",\n  \"fastback.help.command.local\"                  : \"Reliza una copia de seguridad local inmediatamente.\",\n  \"fastback.help.command.prune\"                  : \"Elimina instantáneas antiguas de acuerdo a la política de retención.\",\n  \"fastback.help.command.remote-delete\"          : \"Elimina una instantánea remota.\",\n  \"fastback.help.command.remote-list\"            : \"Enumera las instantáneas remotas.\",\n  \"fastback.help.command.remote-prune\"           : \"Elimina instantáneas remotas antiguas de acuerdo a la política de retención remota.\",\n  \"fastback.help.command.remote-restore\"         : \"Restaura una instantánea remota.\",\n  \"fastback.help.command.restore\"                : \"Restaura una instantánea.\",\n  \"fastback.help.command.set\"                    : \"Cambia los ajustes.\",\n  \"fastback.help.command.set-autoback-action\"    : \"Establece una acción a ejecutar durante las copias de seguridad automáticas.\",\n  \"fastback.help.command.set-autoback-wait\"      : \"Establece el tiempo mínimo entre copias de seguridad automáticas en minutos.\",\n  \"fastback.help.command.set-remote\"             : \"Establece la URL para las copias de seguridad remotas.\",\n  \"fastback.help.command.set-remote-retention\"   : \"Establece la política de retención de instantáneas remotas.\",\n  \"fastback.help.command.set-retention\"          : \"Establece la política de retención de copias de seguridad remotas.\",\n  \"fastback.help.command.set-shutdown-action\"    : \"Establece una acción a ejecutar al cerrar.\",\n  \"fastback.help.subcommands\"                    : \"Subcomandos disponibles:\\n%s\\nPara más información respecto a un subcomando, ejecuta: \\n/backup help [subcommand]\\no dirígete a https://pcal43.github.io/fastback\",\n  \"fastback.help.suggest-init\"                   : \"\\nPara empezar, ejecuta '/backup init'\",\n  \"fastback.chat.backup-complete\"                : \"Copia de seguridad completa.\",\n  \"fastback.chat.backup-complete-elapsed\"        : \"Copia de seguridad completa.  Tiempo transcurrido: %s\",\n  \"fastback.chat.create-file-remote-created\"     : \"Repositorio Git creado en %s\\nCopias de seguridad remotas activas para:\\n%s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"El directorio ya existe:\\n%s\",\n  \"fastback.chat.delete-done\"                    : \"Borrada instantánea %s\",\n  \"fastback.chat.disable-already-disabled\"       : \"Las copias de seguridad ya están desactivadas.\",\n  \"fastback.chat.gc-done\"                        : \"Limpieza completa.  %s reclamados.\",\n  \"fastback.chat.commit-failed\"                  : \"La instantánea ha fallado.  Mira el registro para más detalles.\",\n  \"fastback.chat.commit-start\"                   : \"Creando instantánea %s\",\n  \"fastback.chat.info-autoback-action\"           : \"Autoback action: %s\",\n  \"fastback.chat.info-autoback-wait\"             : \"Autoback wait: %s minutes\",\n  \"fastback.chat.info-backup-size\"               : \"Tamaño de la copia de seguridad local: %s\",\n  \"fastback.chat.info-fastback-version\"          : \"Versión de FastBack: %s\",\n  \"fastback.chat.info-header\"                    : \"\\nInformación de FastBack\\n-------------\",\n  \"fastback.chat.info-local-disabled\"            : \"Copias de seguridad locales: desactivadas\",\n  \"fastback.chat.info-local-enabled\"             : \"Copias de seguridad locales: activadas\",\n  \"fastback.chat.info-native-not-installed\"      : \"No se han podido encontrar git y/o git-lfs en tu PATH:\\n%s\\n\\nLas copias de seguridad no pueden realizarse hasta que instales git en tu sistema.  Por favor dirígete a https://pcal43.github.io/fastback/native-git.html para más información.\",\n  \"fastback.chat.info-remote-url\"                : \"URL remota: %s\",\n  \"fastback.chat.info-shutdown-action\"           : \"Acción al cerrar: %s\",\n  \"fastback.chat.info-uuid\"                      : \"UUID de la copia de seguridad: %s\",\n  \"fastback.chat.info-world-size\"                : \"Tamaño del mundo: %s\",\n  \"fastback.chat.internal-error\"                 : \"Ha ocurrido un error inesperado en la copia de seguridad. Mira el registro para más detalles.\",\n  \"fastback.chat.invalid-input\"                  : \"Datos inválidos: %s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"Instantáneas locales:\",\n  \"fastback.chat.missing-argument\"               : \"Argumentos necesarios: %s\",\n  \"fastback.chat.not-enabled\"                    : \"Las copias de seguridad no están activadas en este mundo.  Ejecuta '/backup init'\",\n  \"fastback.chat.ok\"                             : \"ok\",\n  \"fastback.chat.prune-done\"                     : \"Limpiadas %s instantáneas.\",\n  \"fastback.chat.prune-no-default\"               : \"No hay política de borrado de instantáneas antiguas configurada.  Por favor ejecuta /backup set retention-policy\",\n  \"fastback.chat.prune-suggest-gc\"               : \"Ejecuta /backup gc para reclamar espacio en el disco.\",\n  \"fastback.chat.push-failed\"                    : \"Copia de seguridad completa, pero error al subir al entorno remoto.  Mira el registro para más detalles.\",\n  \"fastback.chat.push-started\"                   : \"Subiendo copia de seguridad a %s...\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"El entorno remoto %s es una copia de seguridad de otro mundo.\\nPor favor, configura un nuevo entorno remoto para este mundo.\",\n  \"fastback.chat.remote-delete-done\"             : \"Eliminada instantánea remota %s\",\n  \"fastback.chat.remote-enabled\"                 : \"Activadas copias de seguridad remotas para:\\n%s\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"Ejecuta '/backup set remote-url <remote-url>' para activar las copias de seguridad remotas.\",\n  \"fastback.chat.remote-list-done\"               : \"%d instantáneas encontradas en %s\",\n  \"fastback.chat.remote-no-url\"                  : \"No hay una URL remota establecida.\\nEjecuta '/backup set remote-url <remote-url>'\",\n  \"fastback.chat.remote-set\"                     : \"URL remota establecida a:\\n%s\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"No hay política de retención de instantáneas remotas configurada.\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"No hay política de retención de copias de seguridad remotas configurada.  Ejecuta /backup set remote-retention-policy\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"Política de retención de instantáneas remotas establecida a:\",\n  \"fastback.chat.restore-done\"                   : \"Instantánea establecida a \\n%s\",\n  \"fastback.chat.restore-nosuch\"                 : \"No se encuentra la instantánea %s\",\n  \"fastback.chat.retention-policy-none\"          : \"No hay política de retención de instantáneas configurada.\",\n  \"fastback.chat.retention-policy-not-set\"       : \"No hay política de retención de copias de seguridad configurada.  Ejectura /backup set retention-policy\",\n  \"fastback.chat.retention-policy-set\"           : \"Política de retención de instantáneas establecida a:\",\n  \"fastback.chat.thread-busy\"                    : \"Ya se está ejecutando otra copia de seguridad.  Por favor, espera a que acabe y vuelve a intentarlo.\",\n  \"fastback.chat.thread-waiting\"                 : \"Esperando a que la copia de seguridad en proceso acabe...\",\n  \"fastback.chat.world-save\"                     : \"Guardando el mundo antes de la copia de seguridad...\",\n  \"fastback.hud.local-saving\"                    : \"Guardando copia de seguridad local...\",\n  \"fastback.hud.prune-started\"                   : \"Limpiando copas de seguridad...\",\n  \"fastback.message.backing-up\"                  : \"Realizando copia de seguridad...\",\n  \"fastback.broadcast.message\"                   : \"El servidor está empezando una copia de seguridad.\",\n  \"fastback.retain.all.description\"              : \"Guarda todas las instantáneas: nunca las borres.\",\n  \"fastback.retain.fixed.description\"            : \"Fixed: Mantén las %s instantáneas más recientes.\",\n  \"fastback.retain.daily.description\"            : \"Daily: Mantén las últimas instantáneas de cada día, más las de los últimos %s días\",\n  \"fastback.retain.gfs.description\"              : \"GFS: Mantén todas las copias de seguridad de hoy, las últimas diarias de la última semana, las últimas semanales del último més, y las últimas de cada més\",\n  \"fastback.values.disabled\"                     : \"deshabilitado\",\n  \"fastback.values.enabled\"                      : \"habilitado\",\n  \"fastback.values.none\"                         : \"ninguno/a\",\n  \"fastback.values.not-installed\"                : \"no instalado\"\n}\n"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/ja_jp.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"ファイルシステムにリモートバックアップターゲットを作成します.\",\n  \"fastback.help.command.delete\"                 : \"個々のスナップショットを削除します\",\n  \"fastback.help.command.disable\"                : \"このワールドでバックアップを無効にする\",\n  \"fastback.help.command.enable\"                 : \"このワールドでローカルバックアップを有効にする\",\n  \"fastback.help.command.full\"                   : \"直ちにローカルとリモートのバックアップを実行\",\n  \"fastback.help.command.gc\"                     : \"ガベージコレクションを実行してディスク領域を解放する\",\n  \"fastback.help.command.help\"                   : \"コマンドのヘルプを表示\",\n  \"fastback.help.command.info\"                   : \"現在のバックアップ状態と設定に関する情報を表示\",\n  \"fastback.help.command.init\"                   : \"このワールドで fastback を初期化します。これを最初に実行してください\",\n  \"fastback.help.command.list\"                   : \"このワールドのバックアップスナップショットの一覧を表示\",\n  \"fastback.help.command.local\"                  : \"直ちにローカルバックアップを実行\",\n  \"fastback.help.command.prune\"                  : \"リテンションポリシーに従って古いスナップショットを削除\",\n  \"fastback.help.command.push\"                   : \"スナップショットをリモートにプッシュ\",\n  \"fastback.help.command.remote-delete\"          : \"リモートスナップショットを削除\",\n  \"fastback.help.command.remote-list\"            : \"リモートスナップショットの一覧を表示\",\n  \"fastback.help.command.remote-prune\"           : \"リモートリテンションポリシーに従って、リモートバックアップから古いスナップショットを削除\",\n  \"fastback.help.command.remote-restore\"         : \"リモートスナップショットを復元\",\n  \"fastback.help.command.restore\"                : \"バックアップスナップショットを復元\",\n  \"fastback.help.command.set\"                    : \"構成設定の変更\",\n  \"fastback.help.command.set-autoback-action\"    : \"自動バックアップ中に実行するアクションを設定\",\n  \"fastback.help.command.set-autoback-wait\"      : \"自動バックアップを行う間隔を設定します\",\n  \"fastback.help.command.set-remote\"             : \"リモートバックアップのURLを設定\",\n  \"fastback.help.command.set-remote-retention\"   : \"リモートバックアップの、スナップショットリテンションポリシーを設定\",\n  \"fastback.help.command.set-retention\"          : \"スナップショットリテンションポリシーを設定\",\n  \"fastback.help.command.set-shutdown-action\"    : \"シャットダウン時に実行するアクションを設定\",\n  \"fastback.help.subcommands\"                    : \"使用可能なサブコマンド:\\n%s\\nサブコマンドの詳細を表示するには\\n/backup help [subcommand] を実行してください\\nまたは、こちらを参照してください https://pcal43.github.io/fastback\",\n  \"fastback.help.suggest-init\"                   : \"\\n開始するには '/backup init' を実行してください\",\n  \"fastback.help.backup-start\"                   : \"バックアップ中 %s\",\n  \"fastback.chat.backup-complete\"                : \"バックアップ完了\",\n  \"fastback.chat.backup-complete-elapsed\"        : \"バックアップ完了  経過時間: %s\",\n  \"fastback.chat.create-file-remote-created\"     : \"Gitリポジトリが %s に作成されました\\nリモートバックアップが有効になりました。\\n保存先: %s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"ディレクトリはすでに存在します:\\n%s\",\n  \"fastback.chat.delete-start\"                   : \"スナップショットを削除しています %s %s\",\n  \"fastback.chat.delete-done\"                    : \"スナップショットを削除しました %s\",\n  \"fastback.chat.disable-already-disabled\"       : \"バックアップはすでに無効になっています\",\n  \"fastback.chat.gc-done\"                        : \"ガベージコレクション完了  %s reclaimed.\",\n  \"fastback.chat.gc-done-no-reclaim\"             : \"ガベージコレクション完了\",\n  \"fastback.chat.gc-failed\"                      : \"ガベージコレクションに失敗しました  詳細はログを参照してください\",\n  \"fastback.chat.commit-failed\"                  : \"バックアップに失敗しました。 詳細はログを参照してください\",\n  \"fastback.chat.commit-start\"                   : \"バックアップのスナップショットを作成しています %s\",\n  \"fastback.chat.commit-complete\"                : \"コミット完了\",\n  \"fastback.chat.info-autoback-action\"           : \"自動バックアップアクション: %s\",\n  \"fastback.chat.info-autoback-wait\"             : \"自動バックアップ実行間隔: %s分\",\n  \"fastback.chat.info-backup-size\"               : \"ローカルバックアップサイズ: %s\",\n  \"fastback.chat.info-fastback-version\"          : \"FastBack バージョン: %s\",\n  \"fastback.chat.info-header\"                    : \"\\nFastBack 詳細\\n-------------\",\n  \"fastback.chat.info-local-disabled\"            : \"ローカルバックアップ: 無効\",\n  \"fastback.chat.info-local-enabled\"             : \"ローカルバックアップ: 有効\",\n  \"fastback.chat.info-remote-url\"                : \"リモート URL: %s\",\n  \"fastback.chat.info-shutdown-action\"           : \"シャットダウンアクション: %s\",\n  \"fastback.chat.info-uuid\"                      : \"バックアップ UUID: %s\",\n  \"fastback.chat.info-world-size\"                : \"ワールドサイズ: %s\",\n  \"fastback.chat.internal-error\"                 : \"予期せぬバックアップエラーが発生しました。詳細はログを参照してください\",\n  \"fastback.chat.invalid-input\"                  : \"無効な入力: %s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"ローカルスナップショット:\",\n  \"fastback.chat.lockfile-exists\"                : \"バックアップのロックファイルが存在します %s\",\n  \"fastback.chat.lockfile-cleanup-enabled\"       : \"%s, ロックファイルのクリーンアップを試みています...\",\n  \"fastback.chat.missing-argument\"               : \"引数が不足しています: %s\",\n  \"fastback.chat.no-change\"                      : \"変更なし\",\n  \"fastback.chat.enabled\"                        : \"このワールドでは、バックアップは既に有効になっています\",\n  \"fastback.chat.native-disabled\"                : \"Native Git is disabled.  FastBackはバックアップにJGitを使用します\",\n  \"fastback.chat.native-info\"                    : \"git: %s\\ngit-lfs: %s\",\n  \"fastback.chat.native-git-not-installed\"       : \"gitが見つかりませんでした PATH:\\n%s\\n\\ngitをインストールするまでバックアップを実行できません\\n詳しくはこちらをご覧ください https://pcal43.github.io/fastback/native-git.html\",\n  \"fastback.chat.native-lfs-not-installed\"       : \"git-lfsが見つかりませんでした PATH:\\n%s\\n\\ngit-lfsをインストールするまでバックアップを実行できません\\n詳しくはこちらをご覧ください https://pcal43.github.io/fastback/native-git.html\",\n  \"fastback.chat.not-enabled\"                    : \"このワールドでバックアップは有効になっていません。 '/backup init' を実行してください\",\n  \"fastback.chat.ok\"                             : \"ok\",\n  \"fastback.chat.prune-done\"                     : \"%s 件のスナップショットから不要なデータが削除されました\",\n  \"fastback.chat.prune-no-default\"               : \"デフォルトのプルーニングポリシーが設定されていません。 /backup set retention-policy を実行してください\",\n  \"fastback.chat.prune-suggest-gc\"               : \"/backup gc を実行してディスク領域を確保します\",\n  \"fastback.chat.push-failed\"                    : \"ローカルバックアップは成功したが、リモートバックアップに失敗しました。 詳細はログを参照してください\",\n  \"fastback.chat.push-started\"                   : \"%s にバックアップをアップロードしています...\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"リモート %s は別のワールドのバックアップターゲットです。このワールドのバックアップ用に新しいリモートを設定してください\",\n  \"fastback.chat.push-done\"                      : \"%s にバックアップをアップロードしました。\",\n  \"fastback.chat.push-done-elapsed\"              : \"%s にバックアップをアップロードしました。  経過時間: %s\",\n  \"fastback.chat.remote-delete-done\"             : \"%s リモートスナップショットを削除しました\",\n  \"fastback.chat.remote-enabled\"                 : \"リモートバックアップ:\\n%s\\nを有効にしました\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"'/backup set remote-url <remote-url>' を実行してリモートバックアップを有効にします\",\n  \"fastback.chat.remote-list-done\"               : \"%s に %d 件のスナップショットが見つかりました\",\n  \"fastback.chat.remote-no-url\"                  : \"リモートURLが設定されていません\\n'/backup set remote-url <remote-url>' を実行してください\",\n  \"fastback.chat.remote-set\"                     : \"リモートバックアップURLを:\\n%s\\nに設定しました\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"リモートスナップショットリテンションポリシーが設定されていません\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"リモートリテンションポリシーが設定されていません /backup set remote-retention-policy を実行してください\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"リモートスナップショットリテンションポリシーを:%s\\nとして設定しました\",\n  \"fastback.chat.restore-done\"                   : \"スナップショット\\n%s\\nを復元しました\",\n  \"fastback.chat.restore-nosuch\"                 : \"スナップショット %s がありません\",\n  \"fastback.chat.retention-policy-none\"          : \"スナップショットリテンションポリシーが設定されていません\",\n  \"fastback.chat.retention-policy-not-set\"       : \"リテンションポリシーが設定されていません /backup set retention-policy を実行してください\",\n  \"fastback.chat.retention-policy-set\"           : \"スナップショットリテンションポリシーを:%s\\nとして設定しました\",\n  \"fastback.chat.thread-busy\"                    : \"別のバックアップタスクが実行中です  終了するまで待ってから、もう一度お試しください\",\n  \"fastback.chat.thread-waiting\"                 : \"現在のバックアップタスクが完了するのを待っています...\",\n  \"fastback.chat.world-save\"                     : \"バックアップの前にワールドを保存しています...\",\n  \"fastback.hud.local-saving\"                    : \"ローカルバックアップを保存しています...\",\n  \"fastback.hud.prune-started\"                   : \"不要なデータを削除しています...\",\n  \"fastback.message.backing-up\"                  : \"バックアップ中...\",\n  \"fastback.broadcast.message\"                   : \"サーバーがバックアップを開始しています\",\n  \"fastback.retain.all.description\"              : \"すべてのスナップショットを保持し、決して削除しない\",\n  \"fastback.retain.fixed.description\"            : \"修正: 最新のスナップショットを%s件のみ保持\",\n  \"fastback.retain.daily.description\"            : \"Daily: 各日の最新のスナップショットを保持し、さらに過去%s日間のすべてのスナップショットを保持する\",\n  \"fastback.retain.gfs.description\"              : \"GFS: 今日のすべてのバックアップを保持 + 過去1週間の最新の1日ごとのバックアップ + 過去1か月の最新の週ごとのバックアップ + 各月の最新のバックアップを保持\",\n  \"fastback.values.disabled\"                     : \"無効\",\n  \"fastback.values.enabled\"                      : \"有効\",\n  \"fastback.values.none\"                         : \"なし\",\n  \"fastback.values.not-installed\"                : \"インストールされていません\"\n}"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/ru_ru.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"Создать шаблон для внешней резервной копии в файловой системе.\",\n  \"fastback.help.command.delete\"                 : \"Удалить отдельную точку восстановления.\",\n  \"fastback.help.command.disable\"                : \"Отключить резервирование этого мира.\",\n  \"fastback.help.command.enable\"                 : \"Включить локальное резервирование этого мира.\",\n  \"fastback.help.command.full\"                   : \"Запустить полное резервирование немедленно.\",\n  \"fastback.help.command.gc\"                     : \"Выполнить очистку мусора для освобождения дискового пространства.\",\n  \"fastback.help.command.help\"                   : \"Получить справку по доступным командам.\",\n  \"fastback.help.command.info\"                   : \"Показать текущий статус настроек резервирования.\",\n  \"fastback.help.command.list\"                   : \"Показать список точек восстановления этого мира.\",\n  \"fastback.help.command.local\"                  : \"Запустить локальное резервирование немедленно.\",\n  \"fastback.help.command.prune\"                  : \"Удалить старые точки восстановления в соответствии со стратегией сохранения.\",\n  \"fastback.help.command.remote-delete\"          : \"Удалить внешнюю точку восстановления.\",\n  \"fastback.help.command.remote-list\"            : \"Показать список внешних точек восстановления этого мира.\",\n  \"fastback.help.command.remote-prune\"           : \"Удалить старые точки восстановления во внешней резервной копии в соответствии со стратегией сохранения.\",\n  \"fastback.help.command.remote-restore\"         : \"Загрузить внешнюю точку восстановления.\",\n  \"fastback.help.command.restore\"                : \"Загрузить точку восстановления.\",\n  \"fastback.help.command.set-autoback-action\"    : \"Настроить автоматическое резервирование.\",\n  \"fastback.help.command.set-autoback-wait\"      : \"Установить минимальный интервал (в минутах) для автоматического резервирования.\",\n  \"fastback.help.command.set-remote\"             : \"Задать адрес для внешнего резервирования.\",\n  \"fastback.help.command.set-remote-retention\"   : \"Установить стратегию сохранения внешних точек восстановления.\",\n  \"fastback.help.command.set-retention\"          : \"Установить стратегию сохранения точек восстановления.\",\n  \"fastback.help.command.set-shutdown-action\"    : \"Настроить резервирование при закрытии мира.\",\n  \"fastback.help.subcommands\"                    : \"Список доступных аргументов:\\n%s\\nДля получения подробного описания аргумента используйте\\n/backup help [аргумент]\\nили перейдите на https://pcal43.github.io/fastback\",\n  \"fastback.chat.backup-complete\"                : \"Резервирование завершено.\",\n  \"fastback.chat.create-file-remote-created\"     : \"Git-репозиторий создан в %s\\nВнешнее резервирование будет вестись в\\n%s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"Директория уже существует:\\n%s\",\n  \"fastback.chat.delete-done\"                    : \"Удалена точка восстановления %s\",\n  \"fastback.chat.disable-already-disabled\"       : \"Резервирование уже отключено.\",\n  \"fastback.chat.disable-done\"                   : \"Резервирование отключено.\",\n  \"fastback.chat.enable-done\"                    : \"Автоматическое создание точек восстановления при закрытии мира включено.\",\n  \"fastback.chat.gc-done\"                        : \"Очистка мусора завершена.\",\n  \"fastback.chat.info-autoback-action\"           : \"Автоматическое резервирование: %s\",\n  \"fastback.chat.info-autoback-wait\"             : \"Интервал авторезервирования: %s минут\",\n  \"fastback.chat.info-backup-size\"               : \"Размер локальной резервной копии: %s\",\n  \"fastback.chat.info-fastback-version\"          : \"Версия FastBack: %s\",\n  \"fastback.chat.info-local-disabled\"            : \"Локальное резервирование: отключено\",\n  \"fastback.chat.info-local-enabled\"             : \"Локальное резервирование: включено\",\n  \"fastback.chat.info-remote-url\"                : \"Внешний адрес: %s\",\n  \"fastback.chat.info-shutdown-action\"           : \"Резервирование при закрыти мира: %s\",\n  \"fastback.chat.info-uuid\"                      : \"UUID резервной копии: %s\",\n  \"fastback.chat.info-world-size\"                : \"Размер мира: %s\",\n  \"fastback.chat.internal-error\"                 : \"В процессе создания точки восстановления возникла непредвиденная ошибка. Проверьте журнал для подробной информации.\",\n  \"fastback.chat.invalid-input\"                  : \"Некорректный ввод: %s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"Локальные точки восстановления:\",\n  \"fastback.chat.not-enabled\"                    : \"Резервирование этого мира отключено. Используйте \\\"/backup init\\\" для включения.\",\n  \"fastback.chat.prune-done\"                     : \"Удалено точек восстановления: %s.\",\n  \"fastback.chat.prune-no-default\"               : \"Не задана стратегия сохранения точек восстановления. Используйте /backup set retention-policy для выбора стратегии.\",\n  \"fastback.chat.prune-suggest-gc\"               : \"Используйте /backup gc для освобождения дискового пространства.\",\n  \"fastback.chat.push-failed\"                    : \"Локальное резервирование завершено, однако в процессе сохранения данных удалённо возникла ошибка. Проверьте журнал для подробностей.\",\n  \"fastback.chat.push-started\"                   : \"Запуск внешнего резервирования...\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"Адрес %s уже назначен для другого мира.\\n Пожалуйста, настройте новое расположение для резервирования.\",\n  \"fastback.chat.remote-delete-done\"             : \"Удалена внешняя точка восстановления %s\",\n  \"fastback.chat.remote-enabled\"                 : \"Включено внешнее резервирование в:\\n%s\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"Используйте \\\"/backup set remote-url <адрес>\\\" для включения внешнего резервирования.\",\n  \"fastback.chat.remote-list-done\"               : \"Найдено %d точек восстановления в %s\",\n  \"fastback.chat.remote-no-url\"                  : \"Адрес для внешнего резервирования не задан.\\n Используйте \\\"/backup set remote-url <адрес>\\\".\",\n  \"fastback.chat.remote-set\"                     : \"Адрес внешнего резервирования изменён на\\n%s\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"Стратегия сохранения внешних точек восстановления не задана.\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"Стратегия сохранения внешних данных не задана. Используйте \\\"/backup set remote-retention-policy\\\"\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"Стратегия сохранения внешних точек восстановления изменена:\",\n  \"fastback.chat.restore-done\"                   : \"Точка восстановления сохранена как мир по адресу\\n%s\",\n  \"fastback.chat.restore-nosuch\"                 : \"Такой точки восстановления не существует: %s\",\n  \"fastback.chat.retention-policy-none\"          : \"Стратегия сохранения точек восстановления не задана.\",\n  \"fastback.chat.retention-policy-not-set\"       : \"Стратегия сохранения данных не задана. Run /backup set retention-policy\",\n  \"fastback.chat.retention-policy-set\"           : \"Стратегия сохранения точек восстановления изменена:\",\n  \"fastback.chat.thread-busy\"                    : \"В настоящее время выполняется другая задача, связанная с резервированием. Пожалуйста, дождитесь её окончания и попробуйте снова.\",\n  \"fastback.chat.thread-waiting\"                 : \"Ожидание завершения активных задач резервирования...\",\n  \"fastback.hud.local-saving\"                    : \"Сохранение локальной точки восстановления.\",\n  \"fastback.hud.prune-started\"                   : \"Удаление...\",\n  \"fastback.retain.all.description\"              : \"Хранить все точки восстановления, без очистки.\",\n  \"fastback.retain.fixed.description\"            : \"Количество: хранить только %s последних точек восстановления.\",\n  \"fastback.retain.daily.description\"            : \"Подневное: по прошествии %s дней удалять все точки восстановления, кроме последних за сутки.\",\n  \"modmenu.summaryTranslation.fastback\"          : \"Резервирование - быстрое, дополняемое, на основе Git.\",\n  \"modmenu.descriptionTranslation.fastback\"      : \"Создавайте резервные копии вашего мира по методу дополняемых точек восстановления. При сохранении точки резервируются только те части мира, которые были изменены с момента создания предыдущей копии.\\nВозможности:\\n • Резервирование только изменённых файлов\\n • Резервные копии создаются быстрее и компактнее zip\\n • Локальное резервирование\\n • Удалённое резервирование на сетевой диск или Git-сервер\\n • Простая загрузка точек восстановления\\n • Управление посредством команд, и другие функции\"\n}"
  },
  {
    "path": "common/src/main/resources/assets/fastback/lang/zh_cn.json",
    "content": "{\n  \"fastback.help.command.create-file-remote\"     : \"在文件系统上创建远程备份目标。\",\n  \"fastback.help.command.delete\"                 : \"删除单个快照。\",\n  \"fastback.help.command.disable\"                : \"停用此世界的备份。\",\n  \"fastback.help.command.enable\"                 : \"启用此世界的本地备份。\",\n  \"fastback.help.command.full\"                   : \"立即执行本地与远程备份。\",\n  \"fastback.help.command.gc\"                     : \"运行垃圾回收以释放磁盘空间。\",\n  \"fastback.help.command.help\"                   : \"查看命令帮助。\",\n  \"fastback.help.command.info\"                   : \"查看当前备份状态与设置。\",\n  \"fastback.help.command.init\"                   : \"初始化当前世界的 FastBack。请先运行此命令。\",\n  \"fastback.help.command.list\"                   : \"列出此世界的备份快照。\",\n  \"fastback.help.command.local\"                  : \"立即执行本地备份。\",\n  \"fastback.help.command.prune\"                  : \"根据保留策略删除旧快照。\",\n  \"fastback.help.command.push\"                   : \"将快照推送至远程。\",\n  \"fastback.help.command.remote-delete\"          : \"删除远程快照。\",\n  \"fastback.help.command.remote-list\"            : \"列出远程快照。\",\n  \"fastback.help.command.remote-prune\"           : \"根据远程保留策略删除远程旧快照。\",\n  \"fastback.help.command.remote-restore\"         : \"从远程恢复快照。\",\n  \"fastback.help.command.restore\"                : \"恢复备份快照。\",\n  \"fastback.help.command.set\"                    : \"修改配置设置。\",\n  \"fastback.help.command.set-autoback-action\"    : \"设置自动备份时执行的操作。\",\n  \"fastback.help.command.set-autoback-wait\"      : \"设置自动备份的最小等待时间（分钟）。\",\n  \"fastback.help.command.set-remote\"             : \"设置远程备份的 URL。\",\n  \"fastback.help.command.set-remote-retention\"   : \"设置远程备份的快照保留策略。\",\n  \"fastback.help.command.set-retention\"          : \"设置快照保留策略。\",\n  \"fastback.help.command.set-shutdown-action\"    : \"设置关闭服务器时执行的操作。\",\n  \"fastback.help.subcommands\"                    : \"可用子命令：\\n%s\\n查看子命令详细帮助，请运行：\\n/backup help [子命令]\\n或访问：https://pcal43.github.io/fastback\",\n  \"fastback.help.suggest-init\"                   : \"\\n请先输入“/backup init”以开始使用。\",\n  \"fastback.help.backup-start\"                   : \"正在备份 %s\",\n  \"fastback.chat.backup-complete\"                : \"备份完成。\",\n  \"fastback.chat.backup-complete-elapsed\"        : \"备份完成，耗时：%s\",\n  \"fastback.chat.create-file-remote-created\"     : \"已在 %s 创建 Git 仓库\\n远程备份已启用至：\\n%s\",\n  \"fastback.chat.create-file-remote-dir-exists\"  : \"目录已存在：\\n%s\",\n  \"fastback.chat.delete-start\"                   : \"正在删除快照 %s %s\",\n  \"fastback.chat.delete-done\"                    : \"已删除快照 %s\",\n  \"fastback.chat.disable-already-disabled\"       : \"备份已是禁用状态。\",\n  \"fastback.chat.gc-done\"                        : \"垃圾回收完成，已回收 %s。\",\n  \"fastback.chat.gc-done-no-reclaim\"             : \"垃圾回收完成。\",\n  \"fastback.chat.gc-failed\"                      : \"垃圾回收失败，详情请查看日志。\",\n  \"fastback.chat.commit-failed\"                  : \"备份失败，详情请查看日志。\",\n  \"fastback.chat.commit-start\"                   : \"正在创建备份快照 %s\",\n  \"fastback.chat.commit-complete\"                : \"提交完成。\",\n  \"fastback.chat.info-autoback-action\"           : \"自动备份操作：%s\",\n  \"fastback.chat.info-autoback-wait\"             : \"自动备份间隔：%s 分钟\",\n  \"fastback.chat.info-backup-size\"               : \"本地备份大小：%s\",\n  \"fastback.chat.info-fastback-version\"          : \"FastBack 版本：%s\",\n  \"fastback.chat.info-header\"                    : \"\\nFastBack 信息\\n-------------\",\n  \"fastback.chat.info-local-disabled\"            : \"本地备份：已禁用\",\n  \"fastback.chat.info-local-enabled\"             : \"本地备份：已启用\",\n  \"fastback.chat.info-remote-url\"                : \"远程 URL：%s\",\n  \"fastback.chat.info-shutdown-action\"           : \"关闭操作：%s\",\n  \"fastback.chat.info-uuid\"                      : \"备份 UUID：%s\",\n  \"fastback.chat.info-world-size\"                : \"世界大小：%s\",\n  \"fastback.chat.internal-error\"                 : \"备份时发生意外错误，详情请查看日志。\",\n  \"fastback.chat.invalid-input\"                  : \"输入无效：%s\",\n  \"fastback.chat.list-local-snapshots-header\"    : \"本地快照：\",\n  \"fastback.chat.lockfile-exists\"                : \"备份锁文件已存在。%s\",\n  \"fastback.chat.lockfile-cleanup-enabled\"       : \"%s，正在尝试清理锁文件…\",\n  \"fastback.chat.missing-argument\"               : \"缺少参数：%s\",\n  \"fastback.chat.no-change\"                      : \"未更改。\",\n  \"fastback.chat.enabled\"                        : \"此世界已启用备份。\",\n  \"fastback.chat.native-disabled\"                : \"已禁用原生 Git，FastBack 将使用 JGit 进行备份。\",\n  \"fastback.chat.native-info\"                    : \"git：%s\\ngit-lfs：%s\",\n  \"fastback.chat.native-git-not-installed\"       : \"在 PATH 中未找到 git：\\n%s\\n\\n请安装 git 后再执行备份。\\n详情请见：https://pcal43.github.io/fastback/native-git.html\",\n  \"fastback.chat.native-lfs-not-installed\"       : \"在 PATH 中未找到 git-lfs：\\n%s\\n\\n请安装 git-lfs 后再执行备份。\\n详情请见：https://pcal43.github.io/fastback/native-git.html\",\n  \"fastback.chat.not-enabled\"                    : \"此世界未启用备份，请先运行“/backup init”。\",\n  \"fastback.chat.ok\"                             : \"确定\",\n  \"fastback.chat.prune-done\"                     : \"已清理 %s 个快照。\",\n  \"fastback.chat.prune-no-default\"               : \"未配置默认清理策略，请运行 /backup set retention-policy\",\n  \"fastback.chat.prune-suggest-gc\"               : \"可运行 /backup gc 以释放磁盘空间。\",\n  \"fastback.chat.push-failed\"                    : \"本地备份成功，但远程备份失败，详情请查看日志。\",\n  \"fastback.chat.push-started\"                   : \"正在上传备份至 %s…\",\n  \"fastback.chat.push-uuid-mismatch\"             : \"位于 %s 的远程目标属于其他世界。\\n请为本世界配置新的远程目标。\",\n  \"fastback.chat.push-done\"                      : \"备份已上传至 %s\",\n  \"fastback.chat.push-done-elapsed\"              : \"备份已上传至 %s，耗时：%s\",\n  \"fastback.chat.remote-delete-done\"             : \"已删除远程快照 %s\",\n  \"fastback.chat.remote-enabled\"                 : \"已启用远程备份至：\\n%s\",\n  \"fastback.chat.remote-how-to-enable-no-url\"    : \"请运行“/backup set remote-url <远程URL>”以启用远程备份。\",\n  \"fastback.chat.remote-list-done\"               : \"在 %s 找到 %d 个快照\",\n  \"fastback.chat.remote-no-url\"                  : \"未设置远程 URL。\\n请运行“/backup set remote-url <远程URL>”\",\n  \"fastback.chat.remote-set\"                     : \"远程备份 URL 已设置为：\\n%s\",\n  \"fastback.chat.remote-retention-policy-none\"   : \"未设置远程快照保留策略。\",\n  \"fastback.chat.remote-retention-policy-not-set\": \"未设置远程保留策略，请运行 /backup set remote-retention-policy\",\n  \"fastback.chat.remote-retention-policy-set\"    : \"远程快照保留策略已设置为：\",\n  \"fastback.chat.restore-done\"                   : \"快照已恢复至\\n%s\",\n  \"fastback.chat.restore-nosuch\"                 : \"快照 %s 不存在\",\n  \"fastback.chat.retention-policy-none\"          : \"未设置快照保留策略。\",\n  \"fastback.chat.retention-policy-not-set\"       : \"未设置保留策略，请运行 /backup set retention-policy\",\n  \"fastback.chat.retention-policy-set\"           : \"快照保留策略已设置为：\",\n  \"fastback.chat.thread-busy\"                    : \"当前有备份任务正在进行，请等待完成后重试。\",\n  \"fastback.chat.thread-waiting\"                 : \"正在等待当前备份任务完成…\",\n  \"fastback.chat.world-save\"                     : \"正在备份前保存世界…\",\n  \"fastback.hud.local-saving\"                    : \"正在保存本地备份…\",\n  \"fastback.hud.prune-started\"                   : \"正在清理…\",\n  \"fastback.message.backing-up\"                  : \"正在备份…\",\n  \"fastback.broadcast.message\"                   : \"服务器正在开始备份。\",\n  \"fastback.retain.all.description\"              : \"保留全部快照，永不清理。\",\n  \"fastback.retain.fixed.description\"            : \"固定数量：仅保留最近的 %s 个快照。\",\n  \"fastback.retain.daily.description\"            : \"每日：保留每天的最新快照，以及最近 %s 天的所有快照。\",\n  \"fastback.retain.gfs.description\"              : \"GFS：保留今日所有备份 + 最近一周的每日最新备份 + 最近一月的每周最新备份 + 每月最新备份。\",\n  \"fastback.values.disabled\"                     : \"已禁用\",\n  \"fastback.values.enabled\"                      : \"已启用\",\n  \"fastback.values.none\"                         : \"无\",\n  \"fastback.values.not-installed\"                : \"未安装\"\n}\n"
  },
  {
    "path": "common/src/main/resources/fastback.mixins.json",
    "content": "{\n  \"required\": true,\n  \"minVersion\": \"0.8\",\n  \"package\": \"net.pcal.fastback.common.mixins\",\n  \"compatibilityLevel\": \"JAVA_25\",\n  \"mixins\": [\n    \"FileFixerUpperMixin\",\n    \"MinecraftServerMixin\",\n    \"ServerAccessors\",\n    \"SessionAccessors\"\n  ],\n  \"client\": [\n    \"MessageScreenMixin\",\n    \"ScreenAccessors\"\n  ],\n  \"injectors\": {\n    \"defaultRequire\": 1\n  }\n}\n"
  },
  {
    "path": "common/src/main/resources/world/gitattributes-jgit",
    "content": "#\n# DO NOT EDIT.  CHANGES WILL BE OVERWRITTEN.\n#\n# This file is automatically updated by FastBack.  If you need to customize\n# these rules, please read this:\n#\n# https://pcal43.github.io/fastback/advanced.html#disabling-file-updates\n#\n\n\n# jgit configuration\n\n# treat everything as binaries\n* -diff -merge -text -delta\n# ...with a few exceptions (that are unlikely to matter much, tbh).\n*.json diff merge text delta\n*.txt diff merge text delta\n*.properties diff merge text delta\n*.toml diff merge text delta\n*.yaml diff merge text delta\n"
  },
  {
    "path": "common/src/main/resources/world/gitattributes-native",
    "content": "#\n# DO NOT EDIT.  CHANGES WILL BE OVERWRITTEN.\n#\n# This file is automatically updated by FastBack.  If you need to customize\n# these rules, please read this:\n#\n# https://pcal43.github.io/fastback/advanced.html#disabling-file-updates\n#\n\n\n# native git configuration\n\n*.dat filter=lfs diff=lfs merge=lfs -text\n*.dat_old filter=lfs diff=lfs merge=lfs -text\n*.jar filter=lfs diff=lfs merge=lfs -text\n*.jar.disabled filter=lfs diff=lfs merge=lfs -text\n*.mca filter=lfs diff=lfs merge=lfs -text\n*.xz filter=lfs diff=lfs merge=lfs -text\n*.zip filter=lfs diff=lfs merge=lfs -text\n"
  },
  {
    "path": "common/src/main/resources/world/gitignore",
    "content": "#\n# DO NOT EDIT.  CHANGES WILL BE OVERWRITTEN.\n#\n# This file is automatically updated by FastBack.  If you need to customize\n# these rules, please read this:\n#\n# https://pcal43.github.io/fastback/advanced.html#disabling-file-updates\n#\n\nsession.lock\n.DS_Store\n"
  },
  {
    "path": "common/src/test/java/net/pcal/fastback/common/repo/V1SnapshotIdTest.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport com.google.common.collect.ArrayListMultimap;\nimport com.google.common.collect.ListMultimap;\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec;\nimport net.pcal.fastback.common.repo.WorldIdUtils.WorldIdImpl;\nimport org.apache.logging.log4j.LogManager;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.text.ParseException;\nimport java.text.SimpleDateFormat;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.Date;\nimport java.util.List;\nimport java.util.UUID;\n\nimport static net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec.V1;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\n/**\n * @author pcal\n * @since 0.4.0\n */\npublic class V1SnapshotIdTest {\n\n    @BeforeAll\n    public static void setup() {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"mocklogger\")));\n    }\n\n    @Test\n    public void testParseBranch() throws ParseException {\n        final String uuid = UUID.randomUUID().toString();\n        final String date = \"2010-05-08_01-02-03\";\n        final String branchName = \"snapshots/\" + uuid + \"/\" + date;\n        final SnapshotId sid = V1.fromBranch(branchName);\n        assertEquals(date, sid.getShortName());\n        assertEquals(branchName, sid.getBranchName());\n        assertEquals(uuid, sid.getWorldId().toString());\n        final Date parsedDate = new SimpleDateFormat(\"yyyy-MM-dd_HH-mm-ss\").parse(date);\n        assertEquals(parsedDate, sid.getDate());\n    }\n\n    @Test\n    public void testSorting() throws ParseException {\n        final SnapshotId s0 = V1.fromBranch(\"snapshots/\" + UUID.randomUUID() + \"/1977-09-24_01-02-03\");\n        final SnapshotId s1 = V1.fromBranch(\"snapshots/\" + UUID.randomUUID() + \"/2010-05-08_01-02-03\");\n        final SnapshotId s2 = V1.fromBranch(\"snapshots/\" + UUID.randomUUID() + \"/2013-10-02_01-02-03\");\n        List<SnapshotId> list = new ArrayList<>(List.of(s1, s2, s0));\n        Collections.sort(list);\n        assertEquals(List.of(s0, s1, s2), list);\n    }\n\n    @Test\n    public void testSortWorldSnapshots() throws ParseException {\n        final WorldId uuid0 = new WorldIdImpl(UUID.randomUUID().toString());\n        final WorldId uuid1 = new WorldIdImpl(UUID.randomUUID().toString());\n        final ListMultimap<WorldId, SnapshotId> sids = ArrayListMultimap.create();\n\n        final SnapshotId s0 = V1.fromBranch(\"snapshots/\" + uuid0 + \"/1977-09-24_01-02-03\");\n        final SnapshotId s1 = V1.fromBranch(\"snapshots/\" + uuid0 + \"/2010-05-08_01-02-03\");\n        final SnapshotId s2 = V1.fromBranch(\"snapshots/\" + uuid0 + \"/2013-10-02_01-02-03\");\n        sids.put(uuid0, s0);\n        sids.put(uuid0, s1);\n        sids.put(uuid0, s2);\n\n        final SnapshotId s3 = V1.fromBranch(\"snapshots/\" + uuid1 + \"/1977-09-24_01-02-03\");\n        final SnapshotId s4 = V1.fromBranch(\"snapshots/\" + uuid1 + \"/2010-05-08_01-02-03\");\n        final SnapshotId s5 = V1.fromBranch(\"snapshots/\" + uuid1 + \"/2013-10-02_01-02-03\");\n        sids.put(uuid1, s3);\n        sids.put(uuid1, s4);\n        sids.put(uuid1, s5);\n\n        assertEquals(List.of(s0, s1, s2), sorted(sids.get(uuid0)));\n        assertEquals(List.of(s3, s4, s5), sorted(sids.get(uuid1)));\n    }\n\n    private static List<SnapshotId> sorted(Collection<SnapshotId> sids) {\n        List<SnapshotId> out = new ArrayList<>(sids);\n        Collections.sort(out);\n        return out;\n    }\n\n    // so other tests can get at it\n    public static SnapshotId v1sid(WorldId wid, Date date) throws ParseException {\n        return V1.create(wid, SnapshotIdCodec.DATE_FORMAT.format(date));\n    }\n\n    public static SnapshotId v1sid(String wid, Date date) throws ParseException {\n        return V1.create(createWorldId(wid), SnapshotIdCodec.DATE_FORMAT.format(date));\n    }\n\n    public static WorldId createWorldId(String wid) {\n        return new WorldIdImpl(wid);\n    }\n}\n"
  },
  {
    "path": "common/src/test/java/net/pcal/fastback/common/repo/V2SnapshotIdTest.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.repo;\n\nimport com.google.common.collect.ArrayListMultimap;\nimport com.google.common.collect.ListMultimap;\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport org.apache.logging.log4j.LogManager;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.text.ParseException;\nimport java.text.SimpleDateFormat;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.Date;\nimport java.util.List;\n\nimport static net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec.V2;\nimport static net.pcal.fastback.common.repo.V1SnapshotIdTest.createWorldId;\nimport static net.pcal.fastback.common.repo.WorldIdUtils.generateRandomWorldId;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\n/**\n * @author pcal\n * @since 0.15.0\n */\npublic class V2SnapshotIdTest {\n\n    @BeforeAll\n    public static void setup() {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"mocklogger\")));\n    }\n\n    @Test\n    public void testWorldIdGeneration() {\n        for (int i = 0; i < 10000; i++) generateRandomWorldId(4);\n    }\n\n    @Test\n    public void testParseBranch() throws ParseException {\n        final String wid = generateRandomWorldId(4);\n        final String date = \"2010-05-08_01-02-03\";\n        final String branchName = wid + \"/\" + date;\n        final SnapshotId sid = V2.fromBranch(branchName);\n        assertEquals(date, sid.getShortName());\n        assertEquals(branchName, sid.getBranchName());\n        assertEquals(wid, sid.getWorldId().toString());\n        final Date parsedDate = new SimpleDateFormat(\"yyyy-MM-dd_HH-mm-ss\").parse(date);\n        assertEquals(parsedDate, sid.getDate());\n    }\n\n    @Test\n    public void testSorting() throws ParseException {\n        final SnapshotId s0 = V2.fromBranch(generateRandomWorldId(4) + \"/1977-09-24_01-02-03\");\n        final SnapshotId s1 = V2.fromBranch(generateRandomWorldId(10) + \"/2010-05-08_01-02-03\");\n        final SnapshotId s2 = V2.fromBranch(generateRandomWorldId(100) + \"/2013-10-02_01-02-03\");\n        List<SnapshotId> list = new ArrayList<>(List.of(s1, s2, s0));\n        Collections.sort(list);\n        assertEquals(List.of(s0, s1, s2), list);\n    }\n\n    @Test\n    public void testSortWorldSnapshots() throws ParseException {\n        final WorldId wid0 = createWorldId(generateRandomWorldId(4));\n        final WorldId wid1 = createWorldId(generateRandomWorldId(5));\n        final ListMultimap<WorldId, SnapshotId> sids = ArrayListMultimap.create();\n\n        final SnapshotId s0 = V2.fromBranch(wid0 + \"/1977-09-24_01-02-03\");\n        final SnapshotId s1 = V2.fromBranch(wid0 + \"/2010-05-08_01-02-03\");\n        final SnapshotId s2 = V2.fromBranch(wid0 + \"/2013-10-02_01-02-03\");\n        sids.put(wid0, s0);\n        sids.put(wid0, s1);\n        sids.put(wid0, s2);\n\n        final SnapshotId s3 = V2.fromBranch(wid1 + \"/1977-09-24_01-02-03\");\n        final SnapshotId s4 = V2.fromBranch(wid1 + \"/2010-05-08_01-02-03\");\n        final SnapshotId s5 = V2.fromBranch(wid1 + \"/2013-10-02_01-02-03\");\n        sids.put(wid1, s3);\n        sids.put(wid1, s4);\n        sids.put(wid1, s5);\n\n        assertEquals(List.of(s0, s1, s2), sorted(sids.get(wid0)));\n        assertEquals(List.of(s3, s4, s5), sorted(sids.get(wid1)));\n    }\n\n    private static List<SnapshotId> sorted(Collection<SnapshotId> sids) {\n        List<SnapshotId> out = new ArrayList<>(sids);\n        Collections.sort(out);\n        return out;\n    }\n\n}\n"
  },
  {
    "path": "common/src/test/java/net/pcal/fastback/common/retention/DailyRetentionPolicyTest.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.repo.SnapshotId;\nimport net.pcal.fastback.common.repo.WorldId;\nimport org.apache.logging.log4j.LogManager;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.text.ParseException;\nimport java.util.Collection;\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.TreeSet;\nimport java.util.UUID;\n\nimport static net.pcal.fastback.common.repo.V1SnapshotIdTest.createWorldId;\nimport static net.pcal.fastback.common.repo.V1SnapshotIdTest.v1sid;\n\npublic class DailyRetentionPolicyTest {\n\n    private static final long HOUR_MILLIS = 1000 * 60 * 60;\n    private static final long DAY_MILLIS = HOUR_MILLIS * 24;\n\n    @BeforeAll\n    public static void setup() {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"mocklogger\")));\n    }\n\n    @Test\n    public void testDailyRetention() throws ParseException {\n        final WorldId uuid = createWorldId(UUID.randomUUID().toString());\n        long now = new Date().getTime();\n        final SnapshotId todayEvening = v1sid(uuid, new Date(now + now % DAY_MILLIS - (4 * HOUR_MILLIS)));\n        final SnapshotId todayMorning = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS / 2)));\n        final SnapshotId yesterdayA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 30000));\n        final SnapshotId yesterdayB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 20000));\n        final SnapshotId yesterdayC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 10000));\n        final SnapshotId threeDaysAgoA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 30000));\n        final SnapshotId threeDaysAgoB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 20000));\n        final SnapshotId threeDaysAgoC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 10000));\n        final SnapshotId lastWeek = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (7 * DAY_MILLIS)));\n        final SnapshotId lastYearA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 30000));\n        final SnapshotId lastYearB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 20000));\n        final SnapshotId lastYearC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 10000));\n        final int GRACE_PERIOD = 2;\n        TreeSet<SnapshotId> snapshots = new TreeSet<>(Set.of(\n                todayEvening, todayMorning,\n                yesterdayA, yesterdayB, yesterdayC,\n                threeDaysAgoA, threeDaysAgoB, threeDaysAgoC, lastWeek,\n                lastYearA, lastYearB, lastYearC));\n\n        RetentionPolicy policy = DailyRetentionPolicy.DailyRetentionPolicyType.INSTANCE.createPolicy(\n                Map.of(\"gracePeriodDays\", String.valueOf(GRACE_PERIOD)));\n        Collection<SnapshotId> toPruneList = policy.getSnapshotsToPrune(snapshots);\n        Assertions.assertEquals(List.of(threeDaysAgoB, threeDaysAgoA, lastYearB, lastYearA), toPruneList);\n    }\n\n}\n"
  },
  {
    "path": "common/src/test/java/net/pcal/fastback/common/retention/GFSRetentionPolicyTest.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.repo.SnapshotId;\nimport net.pcal.fastback.common.repo.V1SnapshotIdTest;\nimport org.apache.logging.log4j.LogManager;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.text.ParseException;\nimport java.time.LocalDate;\nimport java.time.ZonedDateTime;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.TimeZone;\nimport java.util.TreeSet;\nimport java.util.function.Function;\n\npublic class GFSRetentionPolicyTest {\n\n    @BeforeAll\n    public static void setup() {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"mocklogger\")));\n    }\n\n    @Test\n    public void testGFSRetention() throws ParseException {\n\n        final LocalDate now = LocalDate.of(2023, 2, 23); // this is a wednesday\n        final List<SnapshotId> expectPruned = new ArrayList<>();\n        Function<SnapshotId, SnapshotId> pruned = sid -> {\n            expectPruned.add(sid);\n            return sid;\n        };\n\n        TreeSet<SnapshotId> snapshots = new TreeSet<>(Set.of(\n                sid(2023, 2, 23, 9), sid(2023, 2, 23, 8), sid(2023, 2, 23, 7), // keep everything from today\n                sid(2023, 2, 22, 9), sid(2023, 2, 22, 8), sid(2023, 2, 22, 7), // and yesterday, too\n                // these are on unique days in the past week, should be kept\n                sid(2023, 2, 16, 9), sid(2023, 2, 17, 9), sid(2023, 2, 18, 9),\n                // this one is earlier in the day on the 18th, should be pruned\n                pruned.apply(sid(2023, 2, 18, 8)),\n                // keep only the newest one from the previous week\n                sid(2023, 2, 11, 9), pruned.apply(sid(2023, 2, 7, 8)), pruned.apply(sid(2023, 2, 6, 8)),\n                // same thing the week before that\n                sid(2023, 2, 4, 9), pruned.apply(sid(2023, 1, 31, 8)), pruned.apply(sid(2023, 1, 30, 8)),\n                // and then we get into the previous month - pruning is more aggressive\n                sid(2023, 1, 17), pruned.apply(sid(2023, 1, 9)), pruned.apply(sid(2023, 1, 2)), pruned.apply(sid(2023, 1, 1, 8)),\n                // previous month, same deal...\n                sid(2022, 12, 31), pruned.apply(sid(2022, 12, 15)), pruned.apply(sid(2022, 12, 1)),\n                // and so on\n                sid(2022, 11, 4), pruned.apply(sid(2022, 11, 3)), pruned.apply(sid(2022, 11, 2))\n\n        ));\n        RetentionPolicy policy = GFSRetentionPolicy.GFSRetentionPolicyType.INSTANCE.createPolicy(Collections.emptyMap());\n        ((GFSRetentionPolicy) policy).nowSupplier = () -> now;\n        Collection<SnapshotId> toPruneList = policy.getSnapshotsToPrune(snapshots);\n        Assertions.assertEquals(expectPruned, toPruneList);\n    }\n\n    private static SnapshotId sid(int year, int month, int day) throws ParseException {\n        return sid(year, month, day, 11);\n    }\n\n    private static SnapshotId sid(int year, int month, int day, int hour) throws ParseException {\n        Date date = Date.from(ZonedDateTime.of(LocalDate.of(year, month, day).atTime(hour, 0), TimeZone.getDefault().toZoneId()).toInstant());\n        return V1SnapshotIdTest.v1sid(\"3552efde-b34d-11ed-afa1-0242ac120002\", date);\n    }\n}\n"
  },
  {
    "path": "common/src/test/java/net/pcal/fastback/common/retention/RetentionPolicyCodecTest.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.common.retention;\n\nimport net.pcal.fastback.common.logging.Log4jLogger;\nimport net.pcal.fastback.common.logging.SystemLogger;\nimport net.pcal.fastback.common.logging.UserMessage;\nimport net.pcal.fastback.common.repo.SnapshotId;\nimport org.apache.logging.log4j.LogManager;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\n\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class RetentionPolicyCodecTest {\n\n    @BeforeAll\n    public static void setup() {\n        SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger(\"mocklogger\")));\n    }\n\n    @Test\n    public void testEncodePolicy() {\n        final String encodedPolicy = RetentionPolicyCodec.INSTANCE.encodePolicy(\n                MockRetentionPolicyType.INSTANCE,\n                Map.of(\"foo\", \"bar\", \"baz\", \"bop\", \"bad key\", \"whatever\"));\n        assertEquals(\"mock-policy baz=bop foo=bar\", encodedPolicy);\n    }\n\n    @Test\n    public void testDecodePolicy() {\n        final RetentionPolicy policy = RetentionPolicyCodec.INSTANCE.decodePolicy(\n                List.of(MockRetentionPolicyType.INSTANCE),\n                \"mock-policy foo=bar baz=bop random junk should be ignored\"\n        );\n        assertTrue(policy instanceof MockRetentionPolicy);\n        assertEquals(((MockRetentionPolicy) policy).config, Map.of(\"foo\", \"bar\", \"baz\", \"bop\"));\n    }\n\n    @Test\n    public void testEncodeMap() {\n        String encoded = RetentionPolicyCodec.encodeMap(\n                Map.of(\"foo\", \"bar\", \"baz\", \"bop\", \"bad key\", \"whatever\"));\n        assertEquals(\"baz=bop foo=bar\", encoded);\n    }\n\n    @Test\n    public void testDecodeMap() {\n        final String encoded = \"foo=bar baz=bop random junk should be ignored\";\n        Map<String, String> decoded = RetentionPolicyCodec.decodeMap(encoded);\n        assertEquals(Map.of(\"foo\", \"bar\", \"baz\", \"bop\"), decoded);\n    }\n\n    private static class MockRetentionPolicy implements RetentionPolicy {\n\n        private final Map<String, String> config;\n\n        public MockRetentionPolicy(Map<String, String> config) {\n            this.config = config;\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.raw(\"mock policy\");\n        }\n\n        @Override\n        public Collection<SnapshotId> getSnapshotsToPrune(Set<SnapshotId> fromSnapshots) {\n            throw new IllegalStateException();\n        }\n    }\n\n    private enum MockRetentionPolicyType implements RetentionPolicyType {\n\n        INSTANCE;\n\n        @Override\n        public String getName() {\n            return \"mock-policy\";\n        }\n\n        @Override\n        public List<Parameter<?>> getParameters() {\n            throw new IllegalStateException();\n        }\n\n        @Override\n        public RetentionPolicy createPolicy(Map<String, String> config) {\n            return new MockRetentionPolicy(config);\n        }\n\n        @Override\n        public UserMessage getDescription() {\n            return UserMessage.raw(\"mock retention policy\");\n        }\n    }\n\n    ;\n}\n"
  },
  {
    "path": "docs/_config.yml",
    "content": "title: FastBack - Minecraft World Backups\nlogo: /fastback-icon.png\nremote_theme: just-the-docs/just-the-docs\nsearch_enabled: true\ncolor_scheme: dark\n\naux_links:\n  \"Download\":\n    - \"https://www.curseforge.com/minecraft/mc-mods/fastback/files\"\n  \"Source\":\n    - \"https://github.com/pcal43/fastback\"\n  \"Issues\":\n    - \"https://github.com/pcal43/fastback/issues\"\n  \"Discord\":\n    - \"https://discord.pcal.net\"\n"
  },
  {
    "path": "docs/actions-list.md",
    "content": "Action                 | Use\n---------------------- | ---\n`local`                | Backs up locally only.  Like `/backup local`\n`full`                 | Back up locally and upload.  Like `/backup full`\n`full-gc`              | Do a full backup followed by a `prune` and `gc` to reclaim disk space. <br/> This might slow your game down if scheduled during autosaves.\n`none`                 | Don't do anything\n"
  },
  {
    "path": "docs/advanced.md",
    "content": "---\nlayout: default\ntitle: Advanced Usage\nnav_order: 90\n---\n\n# Advanced Usage\n\nThis page assumes you already know how to use git.  If you don't, you should skip it.\n\n## Disabling file updates\n\nFastBack automatically performs updates to some key git files in your world repo.  Advanced users\ncan disable these updates by changing the repo's git configuration:\n\n| Config Key                              | Use                                                                                           |\n|-----------------------------------------|-----------------------------------------------------------------------------------------------|\n| `fastback.update-gitignore-enabled`     | Defaults to `true`.  Set to `false` to disable automatic updates to the root `.gitignore`     |\n| `fastback.update-gitattributes-enabled` | Defaults to `true`.  Set to `false` to disable automatic updates to the root `.gitattributes` |\n\nFor example, running\n```\ngit config fastback.update-gitignore-enabled false\n```\nin your world folder will disable `.gitignore` updates.  \n\nBe aware that you might miss out on future optimizations or bug fixes in these files; by disabling the \nupdates, you take full responsibility for maintaining them.  As an alternative, you might want to consider \nadding custom `.gitignore` or `.gitattributes` files in subdirectories and letting FastBack continue to\nauto-update the root files.\n\n\n### World UUID\n\nFastBack tries to stop you from mixing remote backup snapshots from different worlds.  This is generally a bad idea both in terms of staying organized and backup performance.\n\nIf you want to live dangerously, you can view or change the UUID of a world by looking at a file in you world save directory: `fastback/world.uuid`.\n\n\n## Manually Restoring a Remote Snapshot\n\nFastBack backups are just regular git repos.  This means you can use the terminal and the `git` command line tool to interact with them.\n\nTo restore from a remote manually using `git`:\n\n1. Install `git`.  Mac and Linux users should already have it; Windows users may need to go [here](https://git-scm.com/downloads).\n\n2. Clone the backup repo and list snapshots\n\n```\ngit clone [repo-url]\ncd [directory that just got created]\ngit branch\n```\n\nThis will list all of your available snapshot branches:\n\n```\nsnapshots/12345678-1234-5678-1234-567812345678/2022-10-02_12_56_33\nsnapshots/12345678-1234-5678-1234-567812345678/2022-10-07_11_49_31\n```\n\nTo retrieve one of them, type:\n\n```\ngit checkout snapshots/12345678-1234-5678-1234-567812345678/2022-10-02_12_56_33\n```\n\nYour world save files will appear in the directory.  You can then copy them into your minecraft installation.\n\n\n## Notes for Dedicated Servers:\n\nBy default, Fastback will broadcast a message when a backup is about to start, so players know that things \nmight get choppy for a bit.\n\nYou can configure this in `[worlddir]/.git/config`:\n\n```\n[fastback]\n\tbroadcast-notice-enabled = true\n\tbroadcast-notice-message = My custom message.\n```\n\n## Debugging\n\nIf things go haywire, you can run\n\n```\n/backup set force-debug-enabled true\n```\n\nto temporarily send debugging output to the minecraft logs.  This can be useful in tracking down problems.\n\n"
  },
  {
    "path": "docs/commands-list.md",
    "content": "\n| Command                           | Use                                                                                      |\n|-----------------------------------|------------------------------------------------------------------------------------------|\n| `init`                            | Initialize fastback for the current world.  Run this first.                              |\n| `help`                            | Get help on commands.                                                                    |\n| `local`                           | Perform a local backup immediately.                                                      |\n| `full`                            | Perform a local backup followed by a remote push (if configured).                        |\n| `restore`                         | Restore a backup snapshot.                                                               |\n| `delete`                          | Delete an individual snapshot.                                                           |\n| `info`                            | Info about current backup state and settings.                                            |\n| `list`                            | List backup snapshots for this world.                                                    |\n| `push`    _NEW_!                  | Push a snapshot to the remote.                                                           |\n| `prune`                           | Delete old snapshots according to the retention policy.                                  |\n| `gc`                              | Run garbage collection to free up disk space.                                            |\n| `create-file-remote`              | Create a remote backup target on the file system.                                        |\n| `remote-delete`                   | Delete a remote snapshot.                                                                |\n| `remote-list`                     | List remote snapshots.                                                                   |\n| `remote-prune`                    | Delete old snapshots from the remote backup according to the remote retention policy.    |\n| `remote-restore`                  | Restore a remote snapshot.                                                               |\n| `set retention-policy`            | Set retention policy for local snapshots.                                                |\n| `set remote-url`                  | Set the url for remote backups.                                                          |\n| `set shutdown-action`             | Set an action to perform on shutdown.                                                    |\n| `set autoback-action`             | Set an action to perform during auto-backups.                                            |\n| `set autoback-wait`               | Set the minimum number of minutes to wait between auto-backups.                          |\n| `set restore-directory`           | Target directory for restored snapshots.  Useful for servers with limited tmp space.     |\n| `set remote-retention-policy`     | Set retention policy for remote snapshots.                                               |\n| `set mods-backup-enabled` _NEW_!  | Whether to also backup mod jars and config files (in `.fastback/mods-backup`)            |\n| `set broadcast-enabled` _NEW_!    | Whether to send a server-wide notice when a backup is starting.                          |\n| `set broadcast-message`_NEW_!     | Customized server-wide notice message.                                                   |\n| `set lock-cleanup-enabled` _NEW_! | Automatic cleanup of orphaned `index.lock` files.  Be careful!                           |\n| `set force-debug-enabled` _NEW_!  | Enable verbose debugging output to the console.  Useful if you're running into problems. |\n\n\n"
  },
  {
    "path": "docs/commands.md",
    "content": "---\nlayout: default\ntitle: Commands\nnav_order: 20\n---\n\n# Commands\n\n*Note: A number of subcommands changed in version 0.15.0.  You can see the docs for version 0.14 [here](https://github.com/pcal43/fastback/blob/727a28247a4d2e6c9040293ab99e71a3c183b504/docs/commands-list.md).*\n\nType `/backup` followed by one of these subcommands:\n\n{% include_relative commands-list.md %}\n"
  },
  {
    "path": "docs/diskspace.md",
    "content": "---\nlayout: default\ntitle: Disk Space\nnav_order: 30\n---\n\n# Managing Disk Space\n\nFastBack makes it easy for you to store lots of snapshots of your world. They save quickly\nand don't use up too much disk space.\n\nBut eventually, you'll probably want to get rid of older snapshots you don't need anymore so\nyou can get some disk space back.\n\n## Pruning Snapshots\n\nTo do this, you can run the `prune` command to delete old snapshots that you don't need anymore.\n\n```\n/backup prune\n```\n\nBy default, this will retain the last snapshot from each day, plus all snapshots\nfrom the last 3 days. All other snapshots will be removed.\n\n## Changing How Snapshots are Retained\n\nYou can change the rules for retaining snapshots by running `set retention-policy`:\n\n```\n/backup set retention-policy [policy] [arguments...]\n```\n\nWhere `[policy]` is one of\n\n{% include_relative retention-list.md %}\n\nFor example, to change the policy to keep the five most-recent snapshots, run:\n\n```\n/backup set retention-policy fixed 5\n```\n\n## Collecting Garbage\n\nThe `prune` command marks the snapshots as unused but does not delete from disk.\nTo actually delete the snapshots and reclaim the disk space they occupy, you need to\n\n```\n/backup gc\n```\n\nWARNING: This command can take a long time (5+ minutes).  For large worlds (1gb+), \nyou may be better off running `git gc` from the command line instead.\n\n## Managing Snapshots on a Remote\n\nYou can also manage snapshots on a remote backup on a similar way using the\n`set remote-retention-policy` and `remote-prune` commands. For example,\n\n```\n/backup set remote-retention-policy daily 7\n```\n\nwill set the retention policy for snapshots in the remote backup to keep all snapshots for the last 7 days\nand at most one snapshot per day before that. Then, you can prune old snapshots on the remote by\nrunning\n\n```\n/backup remote-prune\n```\n\n**Note:** it is *not* possible to perform garbage collection on the remote using minecraft commands; you have\nto run it directly on the server. Many git servers will do this automatically for you but it depends on which\nserver you're using and how it's configured.\n"
  },
  {
    "path": "docs/faq.md",
    "content": "---\nlayout: default\ntitle: FAQ\nnav_order: 99\n---\n\n# FAQ\n\n## What is an Incremental Backup?\n\nSay you're playing for a few hours in a Minecraft world that takes up 5GB but you spend the whole time\njust working on your base.  Minecraft only changes files for the parts of the world you changed, which might\nonly be 50 or 100MB of files.  The other 4.9GB is completely the same as it was before you started playing.\n\nWhen you're done playing, wouldn't it be nice to back up only the parts of the world that changed, without \nmaking a whole new copy of all the stuff that didn't change?\n\nThat's what FastBack does.\n\n## How big of a world can I back up?\n\nFastBack is designed for worlds up to about 5GB.  It may work ok with worlds larger than that; please give it a \ntry and let us know!\n\nBut if you're running a server with a 200GB world, you're probably better off sticking with rsync (or whatever\nyou're using).\n\n## I just turned it on and it's taking a while.  I thought you said this thing was fast?\n\nThe first time you back up, it's going to take a while to establish a 'base' snapshot.  The *next* time you \nback up on top of that base, it will be a lot faster.\n\n## Where are the backups stored?\n\nThe backups are stored inside your world folder, in a secret directory called `.git`. You won't see any files\nin there that you recognize; to get your backups out, you need to use the `/backup restore` command.\n\n## Why did my world folder get so much bigger?\n\nThe first time you do a backup, all the files are backed up in the world folder under `.git`.  But the next\ntime you back up, only changed files will be backed up.  See question above about incremental backups.\n\n*Technical detail: It's just a regular git repository, no shenanigans.*\n\n## Can I back up my world to github?\n\nYou can do a remote backup to any git server, including github.\n\nBut because github has certain [size restrictions](https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github),\nwe'd only recommend using it for smaller worlds (under 500MB).\n\n## I thought git was bad for backups?\n\nGit is a popular source code management tool used by software developers.  And it's *really* good at storing text\nfiles, such as program source code. \n\nBut when it comes to binary files - images or, say, minecraft region files - git's text file magic doesn't work.\nAnd software developers can run into a lot of problems if they carelessly mix binary files with source code in a \ngit repo.\n\nFor this reason, most people think that \"git is bad for binary files.\"  But with careful handling, git can actually \nbe used to store just about anything.\n\nTechnical detail: FastBack disables delta compression, stores each backup snapshot in an orphan branch and \naggressively prunes reflogs and tracking branches.*\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: default\ntitle: FastBack\nnav_order: 1\n---\n\n# FastBack\n*Fast, incremental Minecraft world backups powered by Git*\n\nFastback is a Minecraft mod that backs up your world in incremental snapshots.  When it does a backup,\nit only saves the parts of your world that changed.  \n\nThis means backups are fast.  It also means you can keep snapshots of your world without using up a lot\nof disk space.\n\n**IMPORTANT:** Fastback requires that you have [native git and git-lfs](native-git.md) installed.\n\n## Features\n\n* **Now with Forge support!**\n* Incrementally backup just the changed files\n* Faster, smaller backups than zipping\n* Back up locally\n* Back up remotely to any git server\n* Back up remotely to any network volume (no git server required)\n* Schedule backups to run automatically\n* Easily restore backup snapshots\n* Snapshot pruning, retention policies\n* Include mod jars and config files in backup snapshots\n* Broadcast server-wide notifications during backups \n* LuckPerms support\n* Works on clients and dedicated servers\n* Works on Linux, Mac and Windows\n* ..all with easy-to-use minecraft commands\n\n\n## Road Map\n* Support for restoring remote snapshots\n* Better management of remote snapshots\n* UI for managing backups from the title screen\n* ~~Forge support (maybe)~~\n\n## Acknowledgements\n\n* Russian localization provided by [Felix14-v2](https://github.com/Felix14-v2).\n* Chinese localization provided by [buiawpkgew1](https://github.com/buiawpkgew1) and [CMJNB](https://github.com/CMJNB).\n* Fastback includes and was made possible by the work of committers on these projects:\n  * [JGit](https://www.eclipse.org/jgit/) from The Eclipse Software Foundation\n  * [sshd](https://mina.apache.org/sshd-project/) from The Apache Software Foundation\n  * [JavaEWAH](https://github.com/lemire/javaewah) from Daniel Lemire, et al.\n  * [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api) from lucko\n  * [server-translations-api](https://github.com/NucleoidMC/Server-Translations) from the Fabric Community\n\n## Legal\n \nFastBack is distributed under [GNU Public License version 2](https://github.com/pcal43/fastback/blob/main/LICENSE). \n\nYou can put it in a modpack but please include attribution with a link to this page.\n"
  },
  {
    "path": "docs/native-git.md",
    "content": "---\nlayout: default\ntitle: Native Git Support\nnav_order: 95\n---\n\n# Native Git\n\nAs of version `0.17.1`, Fastback **requires** that you have native `git` and `git-lfs` installed on your machine.\n\n## Installing Native Git\n\nInstalling git is highly dependent on your platform but there are tons of resources on the web describing how to do it. \nHere are some good places to start:\n\n* Installing [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)\n* Installing [git-lfs](https://github.com/git-lfs/git-lfs?tab=readme-ov-file#installing)\n\nNote that both git and git-lfs must be available on the `PATH` of the Minecraft process.\n\n## How do I know if Native Git has been installed correctly?\n\nYou can check the minecraft startup logs for lines that look like this\n```\n[14:39:01] [Render thread/INFO] (fastback) git is installed: git version 2.43.0\n[14:39:01] [Render thread/WARN] (fastback) git-lfs is not installed.\n```\n\nYou can also type `/backup info` into the chat to check.\n\n\n## Older Backups\n\nBackups created prior to version 0.17.1 in non-native mode enabled will continue to function with jgit (and in fact\nare incompatible with native mode in some ways).\n\n## Why is native git required now?\n\nNon-native mode relied on java-based re-implementation of git called JGit.  While JGit is an impressive piece of\nengineering, it has proven to have some annoying differences from native git and is also much less performant.  And\nit's just become too burdensome to support two modes, especially when one of them is unreliable.\n\nRequiring native git also ensures that backups will always be manageable with the standard git command-line tool \n(which is not a guarantee with jgit). \n\n## I can't figure out how to get native git installed.  What do I do?\n\nThis is understandable if you're new to git and/or system administration.  You can ask on the discord channel\nfor help.  But to be completely honest, if you find this process daunting, you might want to consider other\nbackup options.\n"
  },
  {
    "path": "docs/permissions-list.md",
    "content": "\n* `fastback.command`\n* `fastback.command.create-file-remote`\n* `fastback.command.delete`\n* `fastback.command.disable`\n* `fastback.command.enable`\n* `fastback.command.full`\n* `fastback.command.gc`\n* `fastback.command.help`\n* `fastback.command.info`\n* `fastback.command.list`\n* `fastback.command.local`\n* `fastback.command.prune`\n* `fastback.command.remote-delete`\n* `fastback.command.remote-list`\n* `fastback.command.remote-prune`\n* `fastback.command.remote-restore`\n* `fastback.command.restore`\n* `fastback.command.set`\n"
  },
  {
    "path": "docs/permissions.md",
    "content": "---\nlayout: default \ntitle: Permissions \nnav_order: 80\n---\n\n# Permissions\n\nIn single-player mode, `/backup` can be run without enabling cheats.\n\nOn a dedicated server, `/backup` can be run only by level 4 operators.\n\n### LuckPerms Support\n\nFastBack exposes fine-grained permissions via\nthe [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api)\nso that you can do access control in [LuckPerms](https://luckperms.net/).\n\nSupported Permissions:\n\n{% include_relative permissions-list.md %}\n"
  },
  {
    "path": "docs/remote.md",
    "content": "---\nlayout: default\ntitle: Remote Backups\nnav_order: 40\n---\n\n# Remote Backups\n\nAn important part of any backup strategy is to keep a copy of the backup on a different computer.  FastBack makes that easy.\n\n## Setting a remote Backup Target\n\nFastBack can automatically upload copies of your backups to another git repository.  We call this other repository the *remote*.\n\n### Configuring remote backups to a git server\n\nIf you have a git server already running (GitHub, for example), all you need to do is\n* create a repository on the server to store your world's backups\n* get the URL to the repository (e.g., `ssh://192.168.0.99/mygitserver/myworld`)\n\nThen, with your world running in Minecraft, type\n```\n/backup set remote-url ssh://192.168.0.99/mygitserver/myworld\n```\n\n### Configuring remote backups to a file remote\n\nIf you don't have a git server, no problem.  You can also do remote backups to any network drive on\nyour computer.  Just type something like\n\n```\n/backup create-file-remote /path/to/network/volume/minecraft-backups/myworld\n```\n\nYou can configure this to be any valid path on your file system.  But it makes the most sense to do your\nbackups to another machine on your network.\n\nIf you ever need to reattach a world to an existing file remote, you can use the `set-remote` command with a `file://` url.  \nFor the example above, that would be\n\n```\n/backup set remote-url file:///path/to/network/volume/minecraft-backups/myworld\n```\n\n\n## Restoring a Remote Snapshot\n\nSay the unthinkable happens: your hard drive crashes.  Your Minecraft world is lost...unless you've been keeping\nremote backups!\n\nYou can list snapshots from the remote just as you can from your local backup:\n\n```\n/backup remote-list\n2022-09-24_13_23_11\n2022-10-02_12_56_33\n2022-10-07_11_49_31\n```\n\nand then restore one like so:\n\n```\n/backup remote-restore 2022-10-02_12_56_33\nSnapshot restored to\n/home/pcal/minecraft/saves/MyWorld-2022-10-02_12_56_33\n```\n\nJust as with local snapshots, restoring a remote snapshots creates a *new* world; existing worlds are never changed.\nThe path to the restored world will be displayed after you run the command.\n"
  },
  {
    "path": "docs/retention-list.md",
    "content": "Action                 | Use\n---------------------- | ---\n`daily`                | Daily: Keep the last snapshot from each day, plus all snapshots from the last `n` days\n`fixed`                | Fixed: Keep only the `n` most-recent snapshots.\n`gfs`                  | GFS: Keep every backup today + latest daily backup in the last week + latest weekly backup in the last month + latest backup of each month\n`all`                  | Retain all snapshots; never prune\n\n"
  },
  {
    "path": "docs/scheduling.md",
    "content": "---\nlayout: default\ntitle: Scheduling\nnav_order: 20\n---\n\n# Scheduling\n\nYou can schedule backups to run automatically when the world shuts down or auto-saves.\n\n## Backing up on shutdown\n\nBackups can be run whenever the world shuts down (i.e., when exiting a single-player world or\nshutting down a dedicated server). To do this,\n\n```\n/backup set shutdown-action [action]\n```\n\nWhere `[action]` is one of\n\n{% include_relative actions-list.md %}\n\n## Backing up while the game is running\n\nYou can also set backups to run while the game is playing, immediately after the\nregular auto-saves that Minecraft performs every 5 minutes.  To do this,\n\n```\n/backup set autoback-action [action]\n```\n\nWhere `[action]` is one of the actions listed above.\n\nIf you don't want `autoback` backups to run every 5 minutes, you can schedule them to\nrun less-frequently:\n\n```\n/backup set autoback-wait [minutes]\n```\n\nThis sets the minimum wait time between auto-backups.\n\nSo, for example, setting `[minutes]` \nto 120 will cause backups to run *roughly* every two hours; the exact timing will depend \non when the next autosave runs.\n"
  },
  {
    "path": "docs/usage.md",
    "content": "---\nlayout: default\ntitle: Using FastBack\nnav_order: 10\n---\n\n# Using FastBack\n\nFastBack adds a custom `/backup` command that is used for all backup operations.  To get detailed help about\nusing it, just type.\n\n**IMPORTANT:** Fastback requires that you have [native git and git-lfs](native-git.md) installed.\n\n```\n/backup help\n```\n\nThis page explains how to do most common tasks.\n\n## Enabling Backups on a world\n\nTo enable backups on your world, just run\n\n```\n/backup init\n```\n\nyou can then type\n\n```\n/backup local\n```\n\nto do backup right away.  You can then run other commands to set up automatic backups, remote backups\npruning policies and more.  Read on.\n\n\n## Listing available backup snapshots\n\nEvery time FastBack runs, it creates a *snapshot* of your world.  Snapshots are identified by the time \nthey were created.\n\nTo see all snapshots in your backup of the current world, run\n```\n/backup list\n\nAvailable snapshots:\n2022-10-07_10-11-12\n2022-10-02_23-43-01\n2022-09-24_11-32-59\n2022-05-08_08-56-33\n```\n\n\n## Restoring a backup snapshot\n\nYou can restore your world from any snapshot in the backup by running\n\n```\n/backup restore 2022-10-02_10-11-12\nRestoring 2022-10-02_10-11-12 to\n/home/pcal/minecraft/saves/MyWorld-2022-10-02_10-11-12\n```\n\nThis will create a copy of your world as it was when that snapshot was made.  \n\nNote that files in the current world are never touched by `restore`; the restored snapshot is placed in either your `saves` directory (in singleplayer mode) or under your system temp directory (in server mode).  In either case, the full path to restore location will be output when the command completes.\n\nTo look at the restored snapshot, quit the current world and open the restored snapshot world.  (In server mode, you'll have to manually copy\nthe restored files from the location displayed at the end of the command).\n\n"
  },
  {
    "path": "etc/blurb.md",
    "content": "# Fast Backups\n\n*Fast, incremental Minecraft world backups powered by Git*\n\n***NOTE: FastBack is still in alpha.  See [the docs]( https://pcal43.github.io/fastback/#current-limitations) for a list of known issues and limitations.***\n\n\nFast Backups (FastBack, for short) is a Fabric Minecraft mod that backs up your world in incremental snapshots.  When it does a backup, it only saves the parts of your world that changed.  \n\nThis means backups are fast.  It also means you can keep snapshots of your world without using up a lot\nof disk space.\n\n## Features\n\n* Incrementally backup just the changed files\n* Faster, smaller backups than zipping\n* Back up locally\n* Back up remotely to any git server\n* Back up remotely to any network volume (no git server required)\n* Schedule backups to run automatically\n* Easily restore backup snapshots\n* Snapshot pruning, retention policies\n* LuckPerms support\n* Works on clients and dedicated servers\n* Works on Linux, Mac and Windows \n* ..all with easy-to-use minecraft commands\n\n## Questions?\n* [Documentation](https://pcal43.github.io/fastback)\n* [Discord](https://discord.gg/jUP5nSPrjx)\n\n![](https://pcal43.github.io/fastback/savescreen_animation.gif)\n"
  },
  {
    "path": "etc/docgen.sh",
    "content": "#!/bin/sh\n\n#\n# Always run this in the root of the repo\n#\ncd $(git rev-parse --show-toplevel)\n\n#\n# generate commands-list.md\n#\necho '''\n| Command                | Use |\n| ---------------------- | --- |''' > docs/commands-list.md\ncat ./src/main/resources/assets/fastback/lang/en_us.json | \\\njq -r 'to_entries[] |select(.key|match(\"fastback.help.command.*\")) | ([ ([\"| `\", (.key|split(\".\")[3]), \"`\"] | join(\"\")), .value, \"\"] | join(\"|\")) ' \\\n>> docs/commands-list.md\n\n\n#\n# generate permissions-list.md\n#\necho '''\n* `fastback.command`''' > docs/permissions-list.md\ncat ./src/main/resources/assets/fastback/lang/en_us.json | \\\njq -r 'to_entries[] |select(.key|match(\"fastback.help.command.*\")) | ([\"* `fastback.command.\", (.key|split(\".\")[3]), \"`\"]|join(\"\"))' \\\n>> docs/permissions-list.md\n\n"
  },
  {
    "path": "fabric/build.gradle",
    "content": "plugins {\n\tid 'net.fabricmc.fabric-loom'\n\tid 'com.modrinth.minotaur'\n\tid 'net.darkhax.curseforgegradle'\n}\n\nbase {\n\tarchivesName = \"${rootProject.ext.archivesBaseNameFabric}-${project.mod_version}\"\n}\n\nrepositories {\n\tmavenCentral()\n\tmaven { url = 'https://maven.fabricmc.net/' }\n\tmaven { url = 'https://repo.spongepowered.org/maven/' }\n\tmaven { url = 'https://maven.nucleoid.xyz/' }\n}\n\ndependencies {\n\tminecraft \"com.mojang:minecraft:${minecraft_version}\"\n\timplementation \"net.fabricmc:fabric-loader:${fabric_loader_version}\"\n\timplementation \"net.fabricmc.fabric-api:fabric-api:${fabric_api_version}\"\n\n\timplementation(\"me.lucko:fabric-permissions-api:${fabric_permissions_version}\") { transitive = false }\n\tinclude(\"me.lucko:fabric-permissions-api:${fabric_permissions_version}\") { transitive = false }\n\n\timplementation(\"xyz.nucleoid:server-translations-api:${server_translations_version}\")\n\tinclude(\"xyz.nucleoid:server-translations-api:${server_translations_version}\")\n\n\t// jgit and dependencies\n\timplementation(\"org.eclipse.jgit:org.eclipse.jgit:${jgit_version}\") { transitive = false }\n\tinclude(\"org.eclipse.jgit:org.eclipse.jgit:${jgit_version}\") { transitive = false }\n\n\timplementation(\"com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}\") { transitive = false }\n\tinclude(\"com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}\") { transitive = false }\n\n\timplementation(\"org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}\") { transitive = false }\n\tinclude(\"org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}\") { transitive = false }\n\n\timplementation(\"org.apache.sshd:sshd-core:${apache_sshd_version}\") { transitive = false }\n\tinclude(\"org.apache.sshd:sshd-core:${apache_sshd_version}\") { transitive = false }\n\n\timplementation(\"org.apache.sshd:sshd-common:${apache_sshd_version}\") { transitive = false }\n\tinclude(\"org.apache.sshd:sshd-common:${apache_sshd_version}\") { transitive = false }\n\n\timplementation(\"net.i2p.crypto:eddsa:${eddsa_version}\") { transitive = false }\n\tinclude(\"net.i2p.crypto:eddsa:${eddsa_version}\") { transitive = false }\n\n\tcompileOnly project(':common')\n}\n\nprocessResources {\n\tinputs.property \"mod_version\", mod_version\n\tinputs.property \"minecraft_version\", minecraft_version\n\tinputs.property \"java_version\", java_version\n\tinputs.property \"fabric_loader_version\", fabric_loader_version\n\tfilesMatching(\"fabric.mod.json\") {\n\t\texpand  \"mod_version\": mod_version,\n\t\t\t\t\"minecraft_version\": minecraft_version,\n\t\t\t\t\"java_version\": java_version,\n\t\t\t\t\"fabric_loader_version\": fabric_loader_version\n\t}\n}\n\nsourceSets {\n\tmain {\n\t\tjava { srcDir project(\":common\").file(\"src/main/java\") }\n\t\tresources { srcDir project(\":common\").file(\"src/main/resources\") }\n\t}\n}\n\nloom {\n\truns {\n\t\tclient {\n\t\t\tclient()\n\t\t\tsetConfigName(\"Fabric Client\")\n\t\t\tideConfigGenerated(true)\n\t\t\trunDir(\"run\")\n\t\t}\n\t\tserver {\n\t\t\tserver()\n\t\t\tsetConfigName(\"Fabric Server\")\n\t\t\tideConfigGenerated(true)\n\t\t\trunDir(\"run\")\n\t\t}\n\t}\n\tmods {\n\t\t\"fastback\" {\n\t\t\tsourceSet sourceSets.main\n\t\t}\n\t}\n}\n\njar {\n\tfrom(\"LICENSE\") {\n\t\trename { \"${it}_${archives_base_name}\" }\n\t}\n}\n\n// Publishing configuration\nmodrinth {\n\ttoken = System.getenv(\"MODRINTH_TOKEN\")\n\tprojectId = modrinth_projectId\n\tversionNumber = mod_version\n\tversionName = \"${mod_version} (fabric)\"\n\tversionType = \"release\"\n\tuploadFile = jar\n\tchangelog = \"<p><a href='${github_projectUrl}/releases/tag/${mod_version}'>${github_projectUrl}/releases/tag/${mod_version}</a></p>\"\n\tgameVersions = [minecraft_version]\n\tloaders = [\"fabric\"]\n\tdependencies {\n\t\trequired.project \"fabric-api\"\n\t}\n}\n\nimport net.darkhax.curseforgegradle.TaskPublishCurseForge\n\ntasks.register('publishCurseForge', TaskPublishCurseForge) {\n\tapiToken = System.getenv(\"CURSEFORGE_TOKEN\") ?: 'CURSEFORGE_TOKEN NOT_SET'\n\tdef mainFile = upload(curseforge_projectId, jar)\n\tmainFile.releaseType = \"release\"\n\tmainFile.changelog = \"${github_projectUrl}/releases/tag/${mod_version}\"\n\tmainFile.changelogType = \"markdown\"\n\tmainFile.addGameVersion(minecraft_version)\n\tmainFile.addModLoader(\"Fabric\")\n\tdependsOn(jar)\n}\n"
  },
  {
    "path": "fabric/src/main/java/net/pcal/fastback/fabric/FabricClientInitializer.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.fabric;\n\nimport net.fabricmc.api.ClientModInitializer;\nimport net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;\nimport net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;\nimport net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;\nimport net.minecraft.resources.Identifier;\nimport net.pcal.fastback.common.mod.ClientHelper;\nimport net.pcal.fastback.common.mod.Mod;\n\n/**\n * Initializer that runs on the client (both integrated and dedicated-server-from-client).\n *\n * @author pcal\n * @since 0.0.1\n */\npublic class FabricClientInitializer implements ClientModInitializer {\n\n    private static final String MOD_ID = \"fastback\";\n\n    @Override\n    public void onInitializeClient() {\n        ClientLifecycleEvents.CLIENT_STARTED.register(\n                minecraftClient -> {\n                    Mod.initializeForClient(new FabricLoaderHelper(), new ClientHelper(minecraftClient));\n                    HudElementRegistry.addLast(\n                            Identifier.fromNamespaceAndPath(MOD_ID, \"hud\"),\n                            (guiGraphics, deltaTracker) -> Mod.mod().renderHud(guiGraphics)\n                    );\n                }\n        );\n        ServerLifecycleEvents.SERVER_STARTING.register(\n                minecraftServer -> Mod.mod().onWorldStart(minecraftServer)\n        );\n        ServerLifecycleEvents.SERVER_STOPPED.register(\n                minecraftServer -> Mod.mod().onWorldStop()\n        );\n    }\n}\n"
  },
  {
    "path": "fabric/src/main/java/net/pcal/fastback/fabric/FabricLoaderHelper.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.fabric;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport me.lucko.fabric.api.permissions.v0.Permissions;\nimport net.fabricmc.api.EnvType;\nimport net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;\nimport net.fabricmc.loader.api.FabricLoader;\nimport net.fabricmc.loader.api.ModContainer;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.pcal.fastback.common.commands.PermissionsFactory;\nimport net.pcal.fastback.common.mod.LoaderHelper;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Function;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * Base Fabric implementation of {@link LoaderHelper}.\n *\n * @author pcal\n * @since 0.1.0\n */\nclass FabricLoaderHelper implements LoaderHelper {\n\n    private static final String FABRIC_MOD_ID = \"fastback\";\n\n    @Override\n    public String getModVersion() {\n        return FabricLoader.getInstance().getModContainer(FABRIC_MOD_ID)\n                .orElseThrow(() -> new IllegalStateException(\"Could not find loader for \" + FABRIC_MOD_ID))\n                .getMetadata().getVersion().toString();\n    }\n\n    @Override\n    public void addLoaderBackupProperties(Map<String, String> props) {\n        try {\n            final List<String> modList = new ArrayList<>();\n            for (final ModContainer mc : FabricLoader.getInstance().getAllMods()) {\n                modList.add(mc.getMetadata().getId() + ':' + mc.getMetadata().getVersion());\n            }\n            Collections.sort(modList);\n            final StringBuilder modListProp = new StringBuilder();\n            for (final String mod : modList) modListProp.append(mod).append(\", \");\n            props.put(\"fabric-mods\", modListProp.toString());\n        } catch (Exception ohwell) {\n            syslog().error(ohwell);\n        }\n    }\n\n    @Override\n    public Path getSavesDir() {\n        if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) {\n            return FabricLoader.getInstance().getGameDir().resolve(\"saves\");\n        }\n        return null;\n    }\n\n    @Override\n    public Collection<Path> getModsBackupPaths() {\n        final Path gameDir = FabricLoader.getInstance().getGameDir();\n        final List<Path> out = new ArrayList<>();\n        out.add(gameDir.resolve(\"options.txt\"));\n        out.add(gameDir.resolve(\"mods\"));\n        out.add(gameDir.resolve(\"config\"));\n        out.add(gameDir.resolve(\"resourcepacks\"));\n        return out;\n    }\n\n    @Override\n    public void registerBackupCommand(boolean isForClient,\n                                      Function<PermissionsFactory<CommandSourceStack>, LiteralArgumentBuilder<CommandSourceStack>> builder) {\n        final int requiredLevel = isForClient ? 0 : 4;\n        LiteralArgumentBuilder<CommandSourceStack> backupCommand =\n                builder.apply(permName -> Permissions.require(permName, requiredLevel));\n        CommandRegistrationCallback.EVENT.register((dispatcher, regAccess, env) ->\n                dispatcher.register(backupCommand));\n    }\n}\n"
  },
  {
    "path": "fabric/src/main/java/net/pcal/fastback/fabric/FabricServerInitializer.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2022 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\n\npackage net.pcal.fastback.fabric;\n\nimport net.fabricmc.api.DedicatedServerModInitializer;\nimport net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;\nimport net.pcal.fastback.common.mod.Mod;\n\n/**\n * Initializer that runs on a dedicated server.\n *\n * @author pcal\n * @since 0.0.1\n */\npublic class FabricServerInitializer implements DedicatedServerModInitializer {\n\n    @Override\n    public void onInitializeServer() {\n        Mod.initializeForDedicatedServer(new FabricLoaderHelper());\n        ServerLifecycleEvents.SERVER_STARTING.register(\n                minecraftServer -> Mod.mod().onWorldStart(minecraftServer)\n        );\n        ServerLifecycleEvents.SERVER_STOPPED.register(\n                minecraftServer -> Mod.mod().onWorldStop()\n        );\n    }\n}\n"
  },
  {
    "path": "fabric/src/main/resources/fabric.mod.json",
    "content": "{\n  \"schemaVersion\": 1,\n  \"id\": \"fastback\",\n  \"version\": \"${mod_version}\",\n  \"name\": \"FastBack\",\n  \"description\": \"Fast, incremental world backups powered by git\",\n  \"authors\": [\"pcal.net\"],\n\n  \"contact\": {\n    \"homepage\": \"https://pcal43.github.io/fastback/\",\n    \"sources\": \"https://github.com/pcal43/fastback\"\n  },\n  \"license\": \"GPL-2\",\n  \"icon\": \"fastback-icon.png\",\n  \"environment\": \"*\",\n  \"entrypoints\": {\n    \"client\": [\n      \"net.pcal.fastback.fabric.FabricClientInitializer\"\n    ],\n    \"server\": [\n      \"net.pcal.fastback.fabric.FabricServerInitializer\"\n    ]\n  },\n  \"mixins\": [\n    \"fastback.mixins.json\"\n  ],\n  \"depends\": {\n    \"fabricloader\": \">=${fabric_loader_version}\",\n    \"fabric-api\": \"*\",\n    \"fabric-permissions-api-v0\": \"*\",\n    \"minecraft\": \"26.1.x\",\n    \"java\": \">=25\"\n  },\n  \"conflicts\": {}\n}\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.5.0-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "#\n# Mod\n#\nmod_version = 0.33.1+26.1.2-prerelease\narchives_base_name = fastback\ngithub_projectUrl = https://github.com/pcal43/fastback\nmodrinth_projectId = ZHKrK8Rp\ncurseforge_projectId = 667417\n\n#\n# Minecraft\n#\nminecraft_version=26.1.2\njava_version = 25\n\n#\n# Fabric - https://fabricmc.net/develop\n#\nfabric_loader_version=0.19.1\nfabric_loom_version=1.16-SNAPSHOT\nfabric_api_version=0.145.4+26.1.2\n\n#\n# NeoForge - https://neoforged.net/\n#\nneoforge_version = 26.1.2.8-beta\nneoforge_moddev_version = 2.0.140\n\n#\n# Dependencies\n#\n\n# https://plugins.gradle.org/plugin/net.darkhax.curseforgegradle\ncurseforgegradle_plugin_version =  1.1.25\n\n# https://plugins.gradle.org/plugin/com.modrinth.minotaur\nminotaur_plugin_version = 2.9.0\n\n# https://mvnrepository.com/artifact/org.spongepowered/mixin\nspongepowered_version = 0.8.5\n\n# https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter/versions\njunit_version = 5.14.4\njunit_platform_version = 1.14.4\n\n# https://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit\njgit_version                 = 7.4.0.202509020913-r\n\n# https://mvnrepository.com/artifact/org.apache.sshd/sshd-core\napache_sshd_version          = 2.16.0\n\n# https://mvnrepository.com/artifact/com.googlecode.javaewah/JavaEWAH\nJavaEWAH_version             = 1.2.3\n\n# https://mvnrepository.com/artifact/net.i2p.crypto/eddsa\neddsa_version                = 0.3.0\n\n# https://github.com/lucko/fabric-permissions-api/releases\nfabric_permissions_version   = 0.7.0\n\n# https://github.com/NucleoidMC/Server-Translations/releases\n# https://maven.nucleoid.xyz/xyz/nucleoid/server-translations-api/\nserver_translations_version  = 3.0.3+26.1\n\n#\n# Build settings\n#\norg.gradle.daemon            = true\norg.gradle.parallel          = true\norg.gradle.jvmargs           = -Xmx4G\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n    CLASSPATH=$( cygpath --path --mixed \"$CLASSPATH\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -classpath \"$CLASSPATH\" \\\n        org.gradle.wrapper.GradleWrapperMain \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif %ERRORLEVEL% equ 0 goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nset EXIT_CODE=%ERRORLEVEL%\r\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\r\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\r\nexit /b %EXIT_CODE%\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "neoforge/build.gradle",
    "content": "plugins {\n    id 'net.neoforged.moddev'\n    id 'com.modrinth.minotaur'\n    id 'net.darkhax.curseforgegradle'\n}\n\nbase {\n    archivesName = \"${rootProject.ext.archivesBaseNameNeoForge}-${project.mod_version}\"\n}\n\nrepositories {\n    mavenCentral()\n    maven { url = 'https://maven.neoforged.net/' }\n}\n\ndependencies {\n    // common module - needed for IntelliJ to resolve the project dependency\n    compileOnly project(':common')\n\n    // jgit and dependencies\n    implementation(\"org.eclipse.jgit:org.eclipse.jgit:${jgit_version}\") { transitive = false }\n    jarJar(\"org.eclipse.jgit:org.eclipse.jgit:${jgit_version}\") { transitive = false }\n\n    implementation(\"com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}\") { transitive = false }\n    jarJar(\"com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}\") { transitive = false }\n\n    implementation(\"org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}\") { transitive = false }\n    jarJar(\"org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}\") { transitive = false }\n\n    implementation(\"org.apache.sshd:sshd-core:${apache_sshd_version}\") { transitive = false }\n    jarJar(\"org.apache.sshd:sshd-core:${apache_sshd_version}\") { transitive = false }\n\n    implementation(\"org.apache.sshd:sshd-common:${apache_sshd_version}\") { transitive = false }\n    jarJar(\"org.apache.sshd:sshd-common:${apache_sshd_version}\") { transitive = false }\n\n    implementation(\"net.i2p.crypto:eddsa:${eddsa_version}\") { transitive = false }\n    jarJar(\"net.i2p.crypto:eddsa:${eddsa_version}\") { transitive = false }\n}\n\nneoForge {\n    version = \"${neoforge_version}\"\n\n    runs {\n        client {\n            client()\n            ideName = \"NeoForge Client\"\n            gameDirectory = project.file(\"run\")\n        }\n        server {\n            server()\n            ideName = \"NeoForge Server\"\n            gameDirectory = project.file(\"run\")\n        }\n    }\n\n    mods {\n        fastback {\n            sourceSet sourceSets.main\n        }\n    }\n}\n\nsourceSets {\n    main {\n        java { srcDir project(\":common\").file(\"src/main/java\") }\n        resources { srcDir project(\":common\").file(\"src/main/resources\") }\n    }\n}\n\nprocessResources {\n    inputs.property \"mod_version\", mod_version\n    inputs.property \"minecraft_version\", minecraft_version\n    inputs.property \"neoforge_version\", neoforge_version\n    filesMatching(\"META-INF/neoforge.mods.toml\") {\n        expand \"mod_version\": mod_version,\n               \"minecraft_version\": minecraft_version,\n               \"neoforge_version\": neoforge_version\n    }\n}\n\njar {\n    from(\"LICENSE\") {\n        rename { \"${it}_${archives_base_name}\" }\n    }\n    manifest {\n        attributes([\n                'MixinConfigs': 'fastback.mixins.json'\n        ])\n    }\n}\n\n// Publishing configuration\nmodrinth {\n    token = System.getenv(\"MODRINTH_TOKEN\")\n    projectId = modrinth_projectId\n    versionNumber = mod_version\n    versionName = \"${mod_version} (neoforge)\"\n    versionType = \"release\"\n    uploadFile = jar\n    changelog = \"<p><a href='${github_projectUrl}/releases/tag/${mod_version}'>${github_projectUrl}/releases/tag/${mod_version}</a></p>\"\n    gameVersions = [minecraft_version]\n    loaders = [\"neoforge\"]\n}\n\nimport net.darkhax.curseforgegradle.TaskPublishCurseForge\n\ntasks.register('publishCurseForge', TaskPublishCurseForge) {\n    apiToken = System.getenv(\"CURSEFORGE_TOKEN\") ?: 'CURSEFORGE_TOKEN NOT_SET'\n    def mainFile = upload(curseforge_projectId, jar)\n    mainFile.releaseType = \"release\"\n    mainFile.changelog = \"${github_projectUrl}/releases/tag/${mod_version}\"\n    mainFile.changelogType = \"markdown\"\n    mainFile.addGameVersion(minecraft_version)\n    mainFile.addModLoader(\"NeoForge\")\n    dependsOn(jar)\n}\n"
  },
  {
    "path": "neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeClientInitializer.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2026 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.neoforge;\n\nimport net.minecraft.client.Minecraft;\nimport net.neoforged.bus.api.IEventBus;\nimport net.neoforged.neoforge.client.event.ClientTickEvent;\nimport net.neoforged.neoforge.client.event.RenderGuiLayerEvent;\nimport net.neoforged.neoforge.common.NeoForge;\nimport net.neoforged.neoforge.event.server.ServerStartingEvent;\nimport net.neoforged.neoforge.event.server.ServerStoppedEvent;\nimport net.pcal.fastback.common.mod.ClientHelper;\nimport net.pcal.fastback.common.mod.Mod;\n\nimport static net.pcal.fastback.common.mod.Mod.mod;\n\n/**\n * Client-side NeoForge initialization. Kept separate from NeoForgeModInitializer\n * so that client-only classes are not classloaded on a dedicated server.\n *\n * @author pcal\n */\nclass NeoForgeClientInitializer {\n\n    static void init(IEventBus modEventBus) {\n        final boolean[] initialized = {false};\n        // We need the Minecraft client instance to build ClientHelper, so we defer until the first tick.\n        NeoForge.EVENT_BUS.addListener((ClientTickEvent.Pre event) -> {\n            if (!initialized[0]) {\n                initialized[0] = true;\n                final Minecraft client = Minecraft.getInstance();\n                Mod.initializeForClient(new NeoForgeLoaderHelper(true), new ClientHelper(client));\n            }\n        });\n        NeoForge.EVENT_BUS.addListener((RenderGuiLayerEvent.Post event) ->\n                mod().renderHud(event.getGuiGraphics()));\n        NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) ->\n                mod().onWorldStart(event.getServer()));\n        NeoForge.EVENT_BUS.addListener((ServerStoppedEvent event) ->\n                mod().onWorldStop());\n    }\n}\n"
  },
  {
    "path": "neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeLoaderHelper.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2026 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.neoforge;\n\nimport com.mojang.brigadier.builder.LiteralArgumentBuilder;\nimport net.minecraft.commands.CommandSourceStack;\nimport net.minecraft.server.permissions.Permission;\nimport net.minecraft.server.permissions.PermissionLevel;\nimport net.neoforged.fml.ModList;\nimport net.neoforged.fml.loading.FMLPaths;\nimport net.neoforged.neoforge.common.NeoForge;\nimport net.neoforged.neoforge.event.RegisterCommandsEvent;\nimport net.pcal.fastback.common.commands.PermissionsFactory;\nimport net.pcal.fastback.common.mod.LoaderHelper;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Function;\n\nimport static net.pcal.fastback.common.logging.SystemLogger.syslog;\n\n/**\n * NeoForge implementation of {@link LoaderHelper}.\n *\n * @author pcal\n */\nclass NeoForgeLoaderHelper implements LoaderHelper {\n\n    static final String NEOFORGE_MOD_ID = \"fastback\";\n\n    private final boolean isClient;\n\n    NeoForgeLoaderHelper(boolean isClient) {\n        this.isClient = isClient;\n    }\n\n    @Override\n    public String getModVersion() {\n        return ModList.get().getModContainerById(NEOFORGE_MOD_ID)\n                .map(c -> c.getModInfo().getVersion().toString())\n                .orElseThrow(() -> new IllegalStateException(\"Could not find mod container for \" + NEOFORGE_MOD_ID));\n    }\n\n    @Override\n    public void addLoaderBackupProperties(Map<String, String> props) {\n        try {\n            final List<String> modList = new ArrayList<>();\n            ModList.get().getMods().forEach(info ->\n                    modList.add(info.getModId() + ':' + info.getVersion()));\n            Collections.sort(modList);\n            final StringBuilder modListProp = new StringBuilder();\n            for (final String mod : modList) modListProp.append(mod).append(\", \");\n            props.put(\"neoforge-mods\", modListProp.toString());\n        } catch (Exception ohwell) {\n            syslog().error(ohwell);\n        }\n    }\n\n    @Override\n    public Path getSavesDir() {\n        return isClient ? FMLPaths.GAMEDIR.get().resolve(\"saves\") : null;\n    }\n\n    @Override\n    public Collection<Path> getModsBackupPaths() {\n        final Path gameDir = FMLPaths.GAMEDIR.get();\n        final List<Path> out = new ArrayList<>();\n        out.add(gameDir.resolve(\"options.txt\"));\n        out.add(gameDir.resolve(\"mods\"));\n        out.add(gameDir.resolve(\"config\"));\n        out.add(gameDir.resolve(\"resourcepacks\"));\n        return out;\n    }\n\n    @Override\n    public void registerBackupCommand(boolean isForClient,\n                                      Function<PermissionsFactory<CommandSourceStack>, LiteralArgumentBuilder<CommandSourceStack>> builder) {\n        final int requiredLevel = isForClient ? 0 : 4;\n        final PermissionLevel permLevel = PermissionLevel.byId(requiredLevel);\n        final LiteralArgumentBuilder<CommandSourceStack> backupCommand =\n                builder.apply(permName -> source -> source.permissions().hasPermission(new Permission.HasCommandLevel(permLevel)));\n        NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> {\n            event.getDispatcher().register(backupCommand);\n            syslog().debug(\"registered backup command\");\n        });\n    }\n}\n"
  },
  {
    "path": "neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeModInitializer.java",
    "content": "/*\n * FastBack - Fast, incremental Minecraft backups powered by Git.\n * Copyright (C) 2026 pcal.net\n *\n * This program is free software; you can redistribute it and/or\n * modify it under the terms of the GNU General Public License\n * as published by the Free Software Foundation; either version 2\n * of the License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program; If not, see <http://www.gnu.org/licenses/>.\n */\npackage net.pcal.fastback.neoforge;\n\nimport net.neoforged.api.distmarker.Dist;\nimport net.neoforged.bus.api.IEventBus;\nimport net.neoforged.fml.ModContainer;\nimport net.neoforged.fml.common.Mod;\nimport net.neoforged.neoforge.common.NeoForge;\nimport net.neoforged.neoforge.event.server.ServerStartingEvent;\nimport net.neoforged.neoforge.event.server.ServerStoppedEvent;\n\nimport static net.pcal.fastback.common.mod.Mod.mod;\nimport static net.pcal.fastback.neoforge.NeoForgeLoaderHelper.NEOFORGE_MOD_ID;\n\n/**\n * NeoForge mod entry point. Handles both dedicated server and client environments.\n *\n * @author pcal\n */\n@Mod(NEOFORGE_MOD_ID)\npublic class NeoForgeModInitializer {\n\n    public NeoForgeModInitializer(IEventBus modEventBus, ModContainer modContainer, Dist dist) {\n        if (dist == Dist.CLIENT) {\n            NeoForgeClientInitializer.init(modEventBus);\n        } else {\n            net.pcal.fastback.common.mod.Mod.initializeForDedicatedServer(new NeoForgeLoaderHelper(false));\n            NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) ->\n                    mod().onWorldStart(event.getServer()));\n            NeoForge.EVENT_BUS.addListener((ServerStoppedEvent event) ->\n                    mod().onWorldStop());\n        }\n    }\n}\n"
  },
  {
    "path": "neoforge/src/main/resources/META-INF/neoforge.mods.toml",
    "content": "modLoader = \"javafml\"\nloaderVersion = \"[4,)\"\nlicense = \"GPL-2\"\n[[mixins]]\nconfig = \"fastback.mixins.json\"\n[[mods]]\nmodId = \"fastback\"\nversion = \"${mod_version}\"\ndisplayName = \"FastBack\"\ndescription = \"Fast, incremental world backups powered by git\"\nlogoFile = \"fastback-icon.png\"\n[[dependencies.fastback]]\nmodId = \"neoforge\"\ntype = \"required\"\nversionRange = \"[${neoforge_version},)\"\nordering = \"NONE\"\nside = \"BOTH\"\n[[dependencies.fastback]]\nmodId = \"minecraft\"\ntype = \"required\"\nversionRange = \"[${minecraft_version},)\"\nordering = \"NONE\"\nside = \"BOTH\"\n"
  },
  {
    "path": "neoforge/src/main/resources/pack.mcmeta",
    "content": "{\n  \"pack\": {\n    \"description\": \"FastBack NeoForge Resources\",\n    \"pack_format\": 34\n  }\n}\n"
  },
  {
    "path": "settings.gradle",
    "content": "pluginManagement {\n    repositories {\n        mavenCentral()\n        gradlePluginPortal()\n        maven { url = 'https://maven.fabricmc.net/' }\n        maven { url = 'https://maven.neoforged.net/' }\n    }\n}\n\nrootProject.name = 'fastback'\ninclude 'common'\ninclude 'fabric'\ninclude 'neoforge'\n"
  }
]