Repository: pcal43/fastback Branch: main Commit: 43f7f506b19e Files: 135 Total size: 460.8 KB Directory structure: gitextract_2zf8f3wn/ ├── .github/ │ └── workflows/ │ └── build-pull-request.yml ├── .gitignore ├── Justfile ├── LICENSE ├── README.md ├── build.gradle ├── common/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── net/ │ │ │ └── pcal/ │ │ │ └── fastback/ │ │ │ └── common/ │ │ │ ├── commands/ │ │ │ │ ├── Command.java │ │ │ │ ├── Commands.java │ │ │ │ ├── CreateFileRemoteCommand.java │ │ │ │ ├── DeleteCommand.java │ │ │ │ ├── FullCommand.java │ │ │ │ ├── GcCommand.java │ │ │ │ ├── HelpCommand.java │ │ │ │ ├── InfoCommand.java │ │ │ │ ├── InitCommand.java │ │ │ │ ├── ListCommand.java │ │ │ │ ├── LocalCommand.java │ │ │ │ ├── PermissionsFactory.java │ │ │ │ ├── PruneCommand.java │ │ │ │ ├── PushCommand.java │ │ │ │ ├── RemoteDeleteCommand.java │ │ │ │ ├── RemoteListCommand.java │ │ │ │ ├── RemotePruneCommand.java │ │ │ │ ├── RemoteRestoreCommand.java │ │ │ │ ├── RestoreCommand.java │ │ │ │ ├── SchedulableAction.java │ │ │ │ ├── SetCommand.java │ │ │ │ └── SnapshotNameSuggestions.java │ │ │ ├── config/ │ │ │ │ ├── FastbackConfigKey.java │ │ │ │ ├── GitConfig.java │ │ │ │ ├── GitConfigImpl.java │ │ │ │ ├── GitConfigKey.java │ │ │ │ └── OtherConfigKey.java │ │ │ ├── logging/ │ │ │ │ ├── AutosaveLogger.java │ │ │ │ ├── CommandLogger.java │ │ │ │ ├── Log4jLogger.java │ │ │ │ ├── ShutdownLogger.java │ │ │ │ ├── SystemLogger.java │ │ │ │ ├── UserLogger.java │ │ │ │ └── UserMessage.java │ │ │ ├── mixins/ │ │ │ │ ├── FileFixerUpperMixin.java │ │ │ │ ├── MessageScreenMixin.java │ │ │ │ ├── MinecraftServerMixin.java │ │ │ │ ├── ScreenAccessors.java │ │ │ │ ├── ServerAccessors.java │ │ │ │ └── SessionAccessors.java │ │ │ ├── mod/ │ │ │ │ ├── AutosaveListener.java │ │ │ │ ├── ClientHelper.java │ │ │ │ ├── LoaderHelper.java │ │ │ │ ├── Mod.java │ │ │ │ ├── ModImpl.java │ │ │ │ └── UserMessageUtil.java │ │ │ ├── repo/ │ │ │ │ ├── BranchUtils.java │ │ │ │ ├── CommitUtils.java │ │ │ │ ├── JGitConsumer.java │ │ │ │ ├── JGitFunction.java │ │ │ │ ├── JGitIncrementalProgressMonitor.java │ │ │ │ ├── JGitPercentageProgressMonitor.java │ │ │ │ ├── JGitSupplier.java │ │ │ │ ├── PreflightUtils.java │ │ │ │ ├── PruneUtils.java │ │ │ │ ├── PushUtils.java │ │ │ │ ├── ReclamationUtils.java │ │ │ │ ├── Repo.java │ │ │ │ ├── RepoFactory.java │ │ │ │ ├── RepoFactoryImpl.java │ │ │ │ ├── RepoImpl.java │ │ │ │ ├── RestoreUtils.java │ │ │ │ ├── SnapshotId.java │ │ │ │ ├── SnapshotIdUtils.java │ │ │ │ ├── WorldId.java │ │ │ │ └── WorldIdUtils.java │ │ │ ├── retention/ │ │ │ │ ├── AllRetentionPolicy.java │ │ │ │ ├── DailyRetentionPolicy.java │ │ │ │ ├── FixedCountRetentionPolicy.java │ │ │ │ ├── GFSRetentionPolicy.java │ │ │ │ ├── RetentionPolicy.java │ │ │ │ ├── RetentionPolicyCodec.java │ │ │ │ └── RetentionPolicyType.java │ │ │ └── utils/ │ │ │ ├── EnvironmentUtils.java │ │ │ ├── Executor.java │ │ │ ├── ExecutorImpl.java │ │ │ ├── FileUtils.java │ │ │ ├── ProcessException.java │ │ │ └── ProcessUtils.java │ │ └── resources/ │ │ ├── assets/ │ │ │ └── fastback/ │ │ │ └── lang/ │ │ │ ├── de_de.json │ │ │ ├── en_us.json │ │ │ ├── es_es.json │ │ │ ├── ja_jp.json │ │ │ ├── ru_ru.json │ │ │ └── zh_cn.json │ │ ├── fastback.mixins.json │ │ └── world/ │ │ ├── gitattributes-jgit │ │ ├── gitattributes-native │ │ └── gitignore │ └── test/ │ └── java/ │ └── net/ │ └── pcal/ │ └── fastback/ │ └── common/ │ ├── repo/ │ │ ├── V1SnapshotIdTest.java │ │ └── V2SnapshotIdTest.java │ └── retention/ │ ├── DailyRetentionPolicyTest.java │ ├── GFSRetentionPolicyTest.java │ └── RetentionPolicyCodecTest.java ├── docs/ │ ├── _config.yml │ ├── actions-list.md │ ├── advanced.md │ ├── commands-list.md │ ├── commands.md │ ├── diskspace.md │ ├── faq.md │ ├── index.md │ ├── native-git.md │ ├── permissions-list.md │ ├── permissions.md │ ├── remote.md │ ├── retention-list.md │ ├── scheduling.md │ └── usage.md ├── etc/ │ ├── blurb.md │ └── docgen.sh ├── fabric/ │ ├── build.gradle │ └── src/ │ └── main/ │ ├── java/ │ │ └── net/ │ │ └── pcal/ │ │ └── fastback/ │ │ └── fabric/ │ │ ├── FabricClientInitializer.java │ │ ├── FabricLoaderHelper.java │ │ └── FabricServerInitializer.java │ └── resources/ │ └── fabric.mod.json ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── neoforge/ │ ├── build.gradle │ └── src/ │ └── main/ │ ├── java/ │ │ └── net/ │ │ └── pcal/ │ │ └── fastback/ │ │ └── neoforge/ │ │ ├── NeoForgeClientInitializer.java │ │ ├── NeoForgeLoaderHelper.java │ │ └── NeoForgeModInitializer.java │ └── resources/ │ ├── META-INF/ │ │ └── neoforge.mods.toml │ └── pack.mcmeta └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build-pull-request.yml ================================================ name: build-pull-request on: [ pull_request ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # this is consistently timing out on github, not sure why # - uses: gradle/actions/wrapper-validation@v3 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 25 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: # Releases are not published with github actions, so I'm less concerned about cache poisoning cache-read-only: false # Aggressively cache Gradle home and build dirs - name: Cache Gradle dependencies uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Cache build outputs uses: actions/cache@v4 with: path: | **/build key: ${{ runner.os }}-build-${{ github.sha }} restore-keys: | ${{ runner.os }}-build- - name: Execute Gradle build run: ./gradlew build ================================================ FILE: .gitignore ================================================ # gradle *~ \#* .gradle/ build/ out/ classes/ # eclipse *.launch # idea .idea/ *.iml *.ipr *.iws # vscode .settings/ .vscode/ .classpath .project */bin # macos *.DS_Store # fabric run/ logs/ ================================================ FILE: Justfile ================================================ clean: rm -rf build compile: ./gradlew compileJava jar: ./gradlew jar release-jars: ./gradlew buildReleaseJars compile-common: ./gradlew :common:compileJava test: ./gradlew test release: ./gradlew release ide: ./gradlew cleanIdea idea pr: gh pr view --web 2>/dev/null || gh pr create --web prs: {{ if os() == "macos" { "open" } else { "firefox" } }} https://github.com/pcal43/copper-hopper/pulls deps: ./gradlew -q dependencies --configuration runtimeClasspath clearCaches: ./gradlew --stop rm -rf "$HOME/.gradle/caches" "$HOME/.gradle/wrapper/dists" "$HOME/.gradle/daemon" "$HOME/.gradle/native" run-fabric: ./gradlew :fabric:runClient run-fabric-server: ./gradlew :fabric:runServer run-neoforge: ./gradlew :neoforge:runClient run-neoforge-server: ./gradlew :neoforge:runServer ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================ FILE: README.md ================================================ # FastBack *Fast, incremental Minecraft world backups powered by Git* Fastback is a 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. This means backups are fast. It also means you can keep snapshots of your world without using up a lot of disk space. ![](https://pcal43.github.io/fastback/savescreen_animation.gif) ## Features * Incrementally backup just the changed files * Faster, smaller backups than zipping * Back up locally * Back up remotely to any git server * Back up remotely to any network volume (no git server required) * Schedule backups to run automatically * Easily restore backup snapshots * Snapshot pruning, retention policies * Include mod jars and config files in backup snapshots * Broadcast server-wide notifications during backups * LuckPerms support * Works on clients and dedicated servers * Works on Linux, Mac and Windows * ..all with easy-to-use minecraft commands ## Acknowledgements * Russian localization provided by [Felix14-v2](https://github.com/Felix14-v2) * Chinese localization provided by [buiawpkgew1](https://github.com/buiawpkgew1) * Spanish localization provided by [rmortes](https://github.com/rmortes) * Fastback includes and was made possible by the work of committers on these projects: * [JGit](https://www.eclipse.org/jgit/) from The Eclipse Software Foundation * [sshd](https://mina.apache.org/sshd-project/) from The Apache Software Foundation * [JavaEWAH](https://github.com/lemire/javaewah) from Daniel Lemire, et al. * [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api) from lucko * [server-translations-api](https://github.com/NucleoidMC/Server-Translations) from the Fabric Community ## Legal FastBack is distributed under [GNU Public License version 2](https://github.com/pcal43/fastback/blob/main/LICENSE). You can put it in a modpack but please include attribution with a link to this page. ## Questions? Please start with the [documentation](https://pcal43.github.io/fastback). If you have more questions, please join "pcal's minecraft mods" on Discord: [https://discord.pcal.net](https://discord.pcal.net) Note that Curseforge comments have been disabled and I will **not** reply to private messages. ================================================ FILE: build.gradle ================================================ plugins { id 'net.fabricmc.fabric-loom' version "${fabric_loom_version}" apply false id 'net.neoforged.moddev' version "${neoforge_moddev_version}" apply false id 'com.modrinth.minotaur' version "${minotaur_plugin_version}" apply false id 'net.darkhax.curseforgegradle' version "${curseforgegradle_plugin_version}" apply false } ext { archivesBaseNameFabric = "${archives_base_name}-fabric" archivesBaseNameNeoForge = "${archives_base_name}-neoforge" } subprojects { tasks.withType(JavaCompile).configureEach { it.options.release = project.java_version as Integer options.encoding = "UTF-8" } } // ============================================================================ // Release Tasks // ============================================================================ /** * Validates that the working directory is clean and on the main branch */ tasks.register('validateRelease') { group = 'release' description = 'Validates that the environment is ready for a release' doFirst { if (!System.getenv("CURSEFORGE_TOKEN")) { throw new GradleException("CURSEFORGE_TOKEN environment variable not set") } if (!System.getenv("MODRINTH_TOKEN")) { throw new GradleException("MODRINTH_TOKEN environment variable not set") } } doLast { // Check git status def gitStatus = 'git status --porcelain'.execute().text.trim() if (!gitStatus.isEmpty()) { throw new GradleException("Working directory not clean, cannot release:\n${gitStatus}") } // Check branch def currentBranch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim() if (currentBranch != 'main') { throw new GradleException("Releases must be performed on main. Currently on '${currentBranch}'") } println "✓ Working directory is clean" println "✓ On main branch" } } /** * Removes the -prerelease suffix from mod_version in gradle.properties */ tasks.register('prepareReleaseVersion') { group = 'release' description = 'Removes -prerelease suffix from mod_version' doLast { def propsFile = file('gradle.properties') def content = propsFile.text // Extract current version using regex def matcher = content =~ /(?m)^mod_version\s*=\s*(.+)$/ if (!matcher.find()) { throw new GradleException("Could not find mod_version in gradle.properties") } def currentVersion = matcher.group(1).trim() println "Current version: ${currentVersion}" if (!currentVersion.contains('-prerelease')) { throw new GradleException("Current version is not a prerelease: ${currentVersion}") } def releaseVersion = currentVersion.replace('-prerelease', '') println "Release version: ${releaseVersion}" // Replace the line in the file, preserving everything else def updatedContent = content.replaceAll( /(?m)^mod_version\s*=\s*.+$/, "mod_version = ${releaseVersion}" ) propsFile.text = updatedContent println "✓ Updated gradle.properties to version ${releaseVersion}" // Store for later tasks project.ext.releaseVersion = releaseVersion } } /** * Commits the version change to git */ tasks.register('commitReleaseVersion') { group = 'release' description = 'Commits the release version to git' doLast { def releaseVersion = project.ext.releaseVersion // Stage and commit def addResult = ["git", "add", "gradle.properties"].execute() def addOutput = new StringBuilder() def addError = new StringBuilder() addResult.consumeProcessOutput(addOutput, addError) addResult.waitFor() if (addResult.exitValue() != 0) { throw new GradleException("Failed to stage gradle.properties: ${addError}") } def commitResult = ["git", "commit", "-m", "*** Release ${releaseVersion} ***"].execute() def commitOutput = new StringBuilder() def commitError = new StringBuilder() commitResult.consumeProcessOutput(commitOutput, commitError) commitResult.waitFor() if (commitResult.exitValue() != 0) { throw new GradleException("Failed to commit release version:\n${commitOutput}\n${commitError}") } println "✓ Committed release version ${releaseVersion}" } } /** * Builds the release jars for both Fabric and NeoForge * Runs as separate Gradle invocation to pick up updated gradle.properties */ tasks.register('buildReleaseJars') { group = 'release' description = 'Builds Fabric and NeoForge release jars' doLast { def releaseVersion = project.ext.releaseVersion println "Building release jars with version ${releaseVersion}..." // Run Gradle as subprocess to pick up updated gradle.properties // Clean first to remove any cached artifacts with old version def gradleCmd = [ './gradlew', 'clean', ':fabric:build', ':neoforge:build', '--console=plain' ] def buildProcess = gradleCmd.execute() buildProcess.waitForProcessOutput(System.out, System.err) if (buildProcess.exitValue() != 0) { throw new GradleException("Failed to build release jars") } println "✓ Built release jars:" println " - fabric/build/libs/${archivesBaseNameFabric}-${releaseVersion}.jar" println " - neoforge/build/libs/${archivesBaseNameNeoForge}-${releaseVersion}.jar" } } /** * Pushes changes and creates a GitHub release */ tasks.register('publishGitHub') { group = 'release' description = 'Pushes to git and creates GitHub release' doLast { def releaseVersion = project.ext.releaseVersion // Push commits println "Pushing to origin..." def pushResult = ["git", "push"].execute() pushResult.waitForProcessOutput(System.out, System.err) if (pushResult.exitValue() != 0) { throw new GradleException("Failed to push to git") } // Create GitHub release println "Creating GitHub release ${releaseVersion}..." def currentBranch = 'git branch --show-current'.execute().text.trim() def fabricJar = file("fabric/build/libs/${archivesBaseNameFabric}-${releaseVersion}.jar") def neoforgeJar = file("neoforge/build/libs/${archivesBaseNameNeoForge}-${releaseVersion}.jar") if (!fabricJar.exists()) throw new GradleException("Fabric release jar not found at ${fabricJar}") if (!neoforgeJar.exists()) throw new GradleException("NeoForge release jar not found at ${neoforgeJar}") def ghCmd = [ 'gh', 'release', 'create', '--target', currentBranch, '--generate-notes', '--title', releaseVersion, '--notes', "release ${releaseVersion}", releaseVersion, fabricJar.absolutePath, neoforgeJar.absolutePath ] def ghResult = ghCmd.execute() ghResult.waitForProcessOutput(System.out, System.err) if (ghResult.exitValue() != 0) { throw new GradleException("Failed to create GitHub release") } println "✓ Created GitHub release ${releaseVersion}" } } /** * Publishes to CurseForge for both Fabric and NeoForge * Runs as separate Gradle invocation to use updated gradle.properties */ tasks.register('publishCurseForge') { group = 'release' description = 'Publishes to CurseForge' doLast { println "Publishing to CurseForge..." def releaseVersion = project.ext.releaseVersion def gradleCmd = [ './gradlew', ':fabric:publishCurseforge', ':neoforge:publishCurseforge', '--console=plain' ] def publishProcess = gradleCmd.execute() publishProcess.waitForProcessOutput(System.out, System.err) if (publishProcess.exitValue() != 0) { throw new GradleException("Failed to publish to CurseForge") } println "✓ Published to CurseForge" } } /** * Publishes to Modrinth for both Fabric and NeoForge * Runs as separate Gradle invocation to use updated gradle.properties */ tasks.register('publishModrinth') { group = 'release' description = 'Publishes to Modrinth' doLast { println "Publishing to Modrinth..." def releaseVersion = project.ext.releaseVersion def gradleCmd = [ './gradlew', ':fabric:modrinth', ':neoforge:modrinth', '--console=plain' ] def publishProcess = gradleCmd.execute() publishProcess.waitForProcessOutput(System.out, System.err) if (publishProcess.exitValue() != 0) { throw new GradleException("Failed to publish to Modrinth") } println "✓ Published to Modrinth" } } /** * Increments version and adds -prerelease suffix for next development cycle */ tasks.register('bumpVersion') { group = 'release' description = 'Increments version and adds -prerelease suffix' doLast { def propsFile = file('gradle.properties') def content = propsFile.text // Extract current version using regex def matcher = content =~ /(?m)^mod_version\s*=\s*(.+)$/ if (!matcher.find()) { throw new GradleException("Could not find mod_version in gradle.properties") } def releaseVersion = matcher.group(1).trim() println "Previous release version: ${releaseVersion}" // Parse the version: "0.23.0+1.21.10" -> major.minor.patch + buildMetadata def versionParts = releaseVersion.split('\\+') def semver = versionParts[0].split('\\.') def buildMetadata = versionParts.length > 1 ? versionParts[1] : '' // Increment patch version def major = semver[0] def minor = semver[1] def patch = (semver[2] as Integer) + 1 def nextVersion = "${major}.${minor}.${patch}+${buildMetadata}-prerelease" println "Next version: ${nextVersion}" // Replace the line in the file, preserving everything else def updatedContent = content.replaceAll( /(?m)^mod_version\s*=\s*.+$/, "mod_version = ${nextVersion}" ) propsFile.text = updatedContent println "✓ Updated gradle.properties to version ${nextVersion}" // Store for later tasks project.ext.nextVersion = nextVersion } } /** * Commits and pushes the version bump */ tasks.register('commitVersionBump') { group = 'release' description = 'Commits and pushes the version bump' doLast { def nextVersion = project.ext.nextVersion // Commit and push def addResult = ["git", "add", "gradle.properties"].execute() def addOutput = new StringBuilder() def addError = new StringBuilder() addResult.consumeProcessOutput(addOutput, addError) addResult.waitFor() if (addResult.exitValue() != 0) { throw new GradleException("Failed to stage gradle.properties: ${addError}") } def commitResult = ["git", "commit", "-m", "Prepare for next version ${nextVersion}"].execute() def commitOutput = new StringBuilder() def commitError = new StringBuilder() commitResult.consumeProcessOutput(commitOutput, commitError) commitResult.waitFor() if (commitResult.exitValue() != 0) { throw new GradleException("Failed to commit version bump:\n${commitOutput}\n${commitError}") } def pushResult = ["git", "push"].execute() pushResult.waitForProcessOutput(System.out, System.err) if (pushResult.exitValue() != 0) { throw new GradleException("Failed to push version bump") } println "✓ Committed and pushed version bump to ${nextVersion}" } } /** * Complete release workflow * * Executes tasks in this order: * 1. validateRelease - Check git status * 2. prepareReleaseVersion - Remove -prerelease suffix * 3. commitReleaseVersion - Commit version change * 4. buildReleaseJars - Build both loaders * 5. publishGitHub - Push and create GitHub release * 6. publishCurseForge - Publish to CurseForge * 7. publishModrinth - Publish to Modrinth * 8. bumpVersion - Increment version, add -prerelease * 9. commitVersionBump - Commit and push next version */ tasks.register('release') { group = 'release' description = 'Complete release: GitHub, CurseForge, Modrinth, and version bump' // All tasks that need to run dependsOn validateRelease, prepareReleaseVersion, commitReleaseVersion, buildReleaseJars, publishGitHub, publishCurseForge, publishModrinth, bumpVersion, commitVersionBump // Execution order prepareReleaseVersion.mustRunAfter validateRelease commitReleaseVersion.mustRunAfter prepareReleaseVersion buildReleaseJars.mustRunAfter commitReleaseVersion publishGitHub.mustRunAfter buildReleaseJars publishCurseForge.mustRunAfter publishGitHub publishModrinth.mustRunAfter publishCurseForge bumpVersion.mustRunAfter publishModrinth commitVersionBump.mustRunAfter bumpVersion doFirst { println "" println "╔════════════════════════════════════════════════════════════╗" println "║ Starting Release Process ║" println "╚════════════════════════════════════════════════════════════╝" println "" } doLast { println "" println "╔════════════════════════════════════════════════════════════╗" println "║ Release Complete! 🎉 ║" println "╚════════════════════════════════════════════════════════════╝" println "" } } ================================================ FILE: common/build.gradle ================================================ plugins { id 'net.fabricmc.fabric-loom' } repositories { maven { url = 'https://maven.fabricmc.net/' } maven { url = 'https://repo.spongepowered.org/maven/' } maven { url = 'https://maven.nucleoid.xyz/' } mavenCentral() } dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" compileOnly "org.spongepowered:mixin:${spongepowered_version}" annotationProcessor "org.spongepowered:mixin:${spongepowered_version}:processor" testImplementation "org.junit.jupiter:junit-jupiter-api:${junit_version}" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junit_version}" testRuntimeOnly "org.junit.platform:junit-platform-launcher:${junit_platform_version}" // jgit api("org.eclipse.jgit:org.eclipse.jgit:${jgit_version}") { transitive = false } // jgit needs this runtimeOnly("com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}") { transitive = false } // So jgit can do modern ssh: // sshd-common is NOT listed here - the fabric module provides a patched version that strips the // FileSystemProvider service entry to prevent a ClassCastException during Fabric's jar scanning. runtimeOnly("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}") { transitive = false } runtimeOnly("org.apache.sshd:sshd-core:${apache_sshd_version}") { transitive = false } // this enables ed25519 support in apache_sshd // https://github.com/apache/mina-sshd/blob/dfa109b7b535d64e8ee395ddd0419e7696fb24ee/docs/dependencies.md runtimeOnly("net.i2p.crypto:eddsa:${project.eddsa_version}") { transitive = false } runtimeOnly("me.lucko:fabric-permissions-api:${fabric_permissions_version}") { transitive = false } runtimeOnly("xyz.nucleoid:server-translations-api:${server_translations_version}") { transitive = false } } loom { runs {} } test { useJUnitPlatform() } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/Command.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; public interface Command { void register(final LiteralArgumentBuilder argb, PermissionsFactory pf); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/Commands.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.repo.RepoFactory; import net.pcal.fastback.common.utils.Executor.ExecutionLock; import java.nio.file.Path; import java.util.function.Predicate; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk; import static net.pcal.fastback.common.utils.Executor.executor; public class Commands { static final int FAILURE = 0; static final int SUCCESS = 1; public static LiteralArgumentBuilder createBackupCommand(final PermissionsFactory pf) { final LiteralArgumentBuilder root = LiteralArgumentBuilder.literal("backup"). requires(pf.require("fastback.command")). executes(HelpCommand::generalHelp); InitCommand.INSTANCE.register(root, pf); LocalCommand.INSTANCE.register(root, pf); FullCommand.INSTANCE.register(root, pf); InfoCommand.INSTANCE.register(root, pf); RestoreCommand.INSTANCE.register(root, pf); CreateFileRemoteCommand.INSTANCE.register(root, pf); PruneCommand.INSTANCE.register(root, pf); DeleteCommand.INSTANCE.register(root, pf); GcCommand.INSTANCE.register(root, pf); ListCommand.INSTANCE.register(root, pf); PushCommand.INSTANCE.register(root, pf); RemoteListCommand.INSTANCE.register(root, pf); RemoteDeleteCommand.INSTANCE.register(root, pf); RemotePruneCommand.INSTANCE.register(root, pf); RemoteRestoreCommand.INSTANCE.register(root, pf); SetCommand.INSTANCE.register(root, pf); HelpCommand.INSTANCE.register(root, pf); return root; } static Predicate subcommandPermission(String subcommandName, PermissionsFactory pf) { final String permName = "fastback.command." + subcommandName; return pf.require(permName); } /** * Retrieve a command argument. If they forgot to provide it, return null * and log a helpful message rather than blowing up the world. This is needed in the * cases where the list of arguments is dynamic (e.g., retention policies) and we can't * rely on brigadier's static parse trees. */ static V getArgumentNicely(final String argName, final Class clazz, final CommandContext cc, UserLogger log) { try { return cc.getArgument(argName, clazz); } catch (IllegalArgumentException iae) { missingArgument(argName, log); return null; } } static int missingArgument(final String argName, final CommandContext cc) { return missingArgument(argName, UserLogger.ulog(cc)); } static int missingArgument(final String argName, final UserLogger log) { log.message(styledLocalized("fastback.chat.missing-argument", ERROR, argName)); return FAILURE; } interface GitOp { void execute(Repo repo) throws Exception; } static void gitOp(final ExecutionLock lock, final UserLogger ulog, final GitOp op) { try { executor().execute(lock, ulog, () -> { final Path worldSaveDir = mod().getWorldDirectory(); final RepoFactory rf = RepoFactory.rf(); if (!rf.isGitRepo(worldSaveDir)) { // FIXME this is not the right place for these checks // If they haven't yet run 'backup init', make sure they've installed native. if (!isNativeOk(true, ulog, true)) return; ulog.message(styledLocalized("fastback.chat.not-enabled", ERROR)); return; } try (final Repo repo = rf.load(worldSaveDir)) { final GitConfig repoConfig = repo.getConfig(); if (!isNativeOk(repoConfig, ulog, false)) return; if (!repoConfig.getBoolean(IS_BACKUP_ENABLED)) { ulog.message(styledLocalized("fastback.chat.not-enabled", ERROR)); } else { op.execute(repo); } } catch (Exception e) { ulog.message(styledLocalized("fastback.chat.internal-error", ERROR)); syslog().error(e); } finally { mod().clearHudText(); } }); } catch (Exception e) { ulog.internalError(); syslog().error(e); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/CreateFileRemoteCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.StoredConfig; import java.nio.file.Path; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.missingArgument; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_FILE_REMOTE_BARE; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; import static net.pcal.fastback.common.utils.FileUtils.mkdirs; enum CreateFileRemoteCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "create-file-remote"; private static final String ARGUMENT = "file-path"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> missingArgument(ARGUMENT, cc)). then(argument(ARGUMENT, StringArgumentType.greedyString()). executes(CreateFileRemoteCommand::setFileRemote) ) ); } private static int setFileRemote(final CommandContext cc) { final UserLogger ulog = UserLogger.ulog(cc); gitOp(NONE, ulog, repo -> { final String targetPath = cc.getArgument(ARGUMENT, String.class); final Path fupHome = Path.of(targetPath); if (fupHome.toFile().exists()) { ulog.message(styledLocalized("fastback.chat.create-file-remote-dir-exists", ERROR, fupHome.toString())); return; } mkdirs(fupHome); GitConfig conf = repo.getConfig(); try (Git targetGit = Git.init().setBare(conf.getBoolean(IS_FILE_REMOTE_BARE)).setDirectory(fupHome.toFile()).call()) { final StoredConfig targetGitc = targetGit.getRepository().getConfig(); targetGitc.setInt("pack", null, "window", 0); targetGitc.setInt("core", null, "bigFileThreshold", 1); targetGitc.save(); } final String targetUrl = "file://" + fupHome.toAbsolutePath(); repo.getConfig().updater().set(REMOTE_PUSH_URL, targetUrl).save(); ulog.message(UserMessage.localized("fastback.chat.create-file-remote-created", targetPath, targetUrl)); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/DeleteCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.List; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.getArgumentNicely; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; enum DeleteCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "delete"; private static final String ARGUMENT = "snapshot"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then(literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)).then( argument(ARGUMENT, StringArgumentType.string()). suggests(SnapshotNameSuggestions.local()). executes(DeleteCommand::delete) ) ); } private static int delete(final CommandContext cc) { final UserLogger log = ulog(cc); gitOp(WRITE, log, repo -> { final String snapshotName = getArgumentNicely(ARGUMENT, String.class, cc.getLastChild(), log); final SnapshotId sid = repo.createSnapshotId(snapshotName); final String branchName = sid.getBranchName(); repo.deleteLocalBranches(List.of(branchName)); log.message(UserMessage.localized("fastback.chat.delete-done", snapshotName)); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/FullCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import java.io.IOException; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; /** * Perform a local backup. * * @author pcal * @since 0.2.0 */ enum FullCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "full"; public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> run(cc.getSource())) ); } public static int run(CommandSourceStack scs) { final UserLogger ulog = ulog(scs); try { saveWorldBeforeBackup(ulog); } catch (IOException e) { ulog.internalError(); syslog().error(e); } gitOp(WRITE, ulog, repo -> repo.doCommitAndPush(ulog)); return SUCCESS; } /** * NOTE: this MUST be called in the game thread; calling it from one of our executor threads causes things * to seize up (at least on shutdown backup?) *

* Workaround for https://github.com/pcal43/fastback/issues/112 */ static void saveWorldBeforeBackup(UserLogger ulog) throws IOException { ulog.message(localized("fastback.chat.world-save")); mod().saveWorld(); ulog.message(localized("fastback.message.backing-up")); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/GcCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; /** * Runs garbage collection to try to free up disk space. * * @author pcal * @since 0.0.12 */ enum GcCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "gc"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(GcCommand::gc) ); } private static int gc(CommandContext cc) { final UserLogger ulog = ulog(cc); gitOp(WRITE, ulog, repo -> { repo.doGc(ulog); //log.chat(localized("fastback.chat.gc-done", byteCountToDisplaySize(gc.getBytesReclaimed()))); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/HelpCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import java.io.StringWriter; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.FAILURE; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoFactory.rf; enum HelpCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "help"; private static final String ARGUMENT = "subcommand"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(HelpCommand::generalHelp). then( argument(ARGUMENT, StringArgumentType.word()). suggests(new HelpTopicSuggestions()). executes(this::subcommandHelp) ) ); } private static class HelpTopicSuggestions implements SuggestionProvider { HelpTopicSuggestions() { } @Override public CompletableFuture getSuggestions(final CommandContext cc, final SuggestionsBuilder builder) { CompletableFuture completableFuture = new CompletableFuture<>(); getSubcommandNames(cc).forEach(builder::suggest); try { completableFuture.complete(builder.buildFuture().get()); } catch (InterruptedException | ExecutionException e) { syslog().error("looking up help topics", e); return null; } return completableFuture; } } static int generalHelp(final CommandContext cc) { try (final UserLogger ulog = ulog(cc)) { StringWriter subcommands = null; for (final String available : getSubcommandNames(cc)) { if (subcommands == null) { subcommands = new StringWriter(); } else { subcommands.append(", "); } subcommands.append(available); } ulog.message(UserMessage.localized("fastback.help.subcommands", String.valueOf(subcommands))); if (!rf().isGitRepo(mod().getWorldDirectory())) { ulog.message(UserMessage.localized("fastback.help.suggest-init")); } return SUCCESS; } } private int subcommandHelp(final CommandContext cc) { try (final UserLogger ulog = ulog(cc)) { final String subcommand = cc.getLastChild().getArgument(ARGUMENT, String.class); for (String available : getSubcommandNames(cc)) { if (subcommand.equals(available)) { final String prefix = "/backup " + subcommand + ": "; ulog.message(UserMessage.localized("fastback.help.command." + subcommand, prefix)); return SUCCESS; } } ulog.message(styledLocalized("fastback.chat.invalid-input", ERROR, subcommand)); } return FAILURE; } private static List getSubcommandNames(CommandContext cc) { final List out = new ArrayList<>(); cc.getNodes().get(0).getNode().getChildren().forEach(node -> out.add(node.getName())); return out; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/InfoCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.config.GitConfigKey; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.retention.RetentionPolicy; import net.pcal.fastback.common.retention.RetentionPolicyCodec; import net.pcal.fastback.common.retention.RetentionPolicyType; import java.util.function.Function; import static java.util.Objects.requireNonNull; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.FAILURE; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY; import static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_RETENTION_POLICY; import static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY; import static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.logging.UserMessage.raw; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoFactory.rf; import static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk; import static org.apache.commons.io.FileUtils.byteCountToDisplaySize; import static org.apache.commons.io.FileUtils.sizeOfDirectory; // TODO move this to Repo.doInfo enum InfoCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "info"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> info(cc.getSource())) ); } private static int info(final CommandSourceStack scs) { requireNonNull(scs); try (final UserLogger ulog = ulog(scs)) { try { ulog.message(UserMessage.localized("fastback.chat.info-header")); ulog.message(UserMessage.localized("fastback.chat.info-fastback-version", mod().getModVersion())); if (!rf().isGitRepo(mod().getWorldDirectory())) { // If they haven't yet run 'backup init', make sure they've installed native. if (!isNativeOk(true, ulog, true)) return FAILURE; } else { try (final Repo repo = rf().load(mod().getWorldDirectory())) { final GitConfig conf = repo.getConfig(); if (!isNativeOk(conf, ulog, true)) return FAILURE; ulog.message(UserMessage.localized("fastback.chat.info-uuid", repo.getWorldId().toString())); // FIXME? this could be implemented more efficiently final long backupSize = sizeOfDirectory(repo.getDirectory()); final long worldSize = sizeOfDirectory(repo.getWorkTree()) - backupSize; ulog.message(UserMessage.localized("fastback.chat.info-world-size", byteCountToDisplaySize(worldSize))); ulog.message(UserMessage.localized("fastback.chat.info-backup-size", byteCountToDisplaySize(backupSize))); show(IS_BACKUP_ENABLED, conf::getBoolean, ulog); show(REMOTE_PUSH_URL, conf::getString, ulog); show(RESTORE_DIRECTORY, conf::getString, ulog); show(AUTOBACK_WAIT_MINUTES, conf::getInt, ulog); show(IS_MODS_BACKUP_ENABLED, conf::getBoolean, ulog); show(BROADCAST_ENABLED, conf::getBoolean, ulog); show(BROADCAST_MESSAGE, conf::getString, ulog); final SchedulableAction shutdownAction = SchedulableAction.forConfigValue(conf.getString(SHUTDOWN_ACTION)); ulog.message(UserMessage.localized("fastback.chat.info-shutdown-action", getActionDisplay(shutdownAction))); final SchedulableAction autobackAction = SchedulableAction.forConfigValue(conf.getString(AUTOBACK_ACTION)); ulog.message(UserMessage.localized("fastback.chat.info-autoback-action", getActionDisplay(autobackAction))); showRetentionPolicy(ulog, conf.getString(LOCAL_RETENTION_POLICY), "fastback.chat.retention-policy-set", "fastback.chat.retention-policy-none" ); showRetentionPolicy(ulog, conf.getString(REMOTE_RETENTION_POLICY), "fastback.chat.remote-retention-policy-set", "fastback.chat.remote-retention-policy-none" ); } } } catch (final Exception e) { ulog.internalError(e); } } return SUCCESS; } private static void show(GitConfigKey key, Function valueFn, UserLogger ulog) { ulog.message(raw(key.getDisplayName() + " = " + valueFn.apply(key))); } private static String getActionDisplay(SchedulableAction action) { return action == null ? SchedulableAction.NONE.getArgumentName() : action.getArgumentName(); } private static void showRetentionPolicy(UserLogger log, String encodedPolicy, String setKey, String noneKey) { if (encodedPolicy == null) { log.message(UserMessage.localized(noneKey)); } else { final RetentionPolicy policy = RetentionPolicyCodec.INSTANCE. decodePolicy(RetentionPolicyType.getAvailable(), encodedPolicy); if (policy == null) { log.message(UserMessage.localized(noneKey)); } else { log.message(UserMessage.localized(setKey)); log.message(policy.getDescription()); } } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/InitCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.RepoFactory; import java.io.IOException; import java.nio.file.Path; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; import static net.pcal.fastback.common.utils.Executor.executor; /** * @author pcal * @since 0.15.0 */ enum InitCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "init"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(InitCommand::init) ); } private static int init(final CommandContext cc) { try (final UserLogger ulog = ulog(cc)) { executor().execute(NONE, ulog, () -> { final Path worldSaveDir = mod().getWorldDirectory(); final RepoFactory rf = RepoFactory.rf(); try { rf.doInit(worldSaveDir, ulog); } catch (IOException e) { throw new RuntimeException(e); } } ); } return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/ListCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.FAILURE; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoFactory.rf; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; enum ListCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "list"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(this::execute) ); } private int execute(final CommandContext cc) { try (final UserLogger ulog = UserLogger.ulog(cc)) { if (!rf().doInitCheck(mod().getWorldDirectory(), ulog)) return FAILURE; gitOp(NONE, ulog, repo -> { final List snapshots = new ArrayList<>(repo.getLocalSnapshots()); Collections.sort(snapshots); for (final SnapshotId sid : snapshots) { ulog.message(UserMessage.raw(sid.getShortName())); } }); } return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/LocalCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.FAILURE; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.commands.FullCommand.saveWorldBeforeBackup; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoFactory.rf; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; /** * Perform a local backup. * * @author pcal * @since 0.2.0 */ enum LocalCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "local"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> run(cc.getSource())) ); } private static int run(CommandSourceStack scs) { try (final UserLogger ulog = ulog(scs)) { if (!rf().doInitCheck(mod().getWorldDirectory(), ulog)) return FAILURE; try { saveWorldBeforeBackup(ulog); } catch (Exception e) { ulog.internalError(); syslog().error(e); } gitOp(WRITE, ulog, repo -> repo.doCommitSnapshot(ulog)); } return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/PermissionsFactory.java ================================================ package net.pcal.fastback.common.commands; /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ import java.util.function.Predicate; public interface PermissionsFactory { Predicate require(String permissionName); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/PruneCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.Collection; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; /** * Command to prune all snapshots that are not to be retained per the retention policy. * * @author pcal * @since 0.2.0 */ enum PruneCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "prune"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> prune(cc.getSource())) ); } private static int prune(final CommandSourceStack scs) { final UserLogger ulog = ulog(scs); gitOp(WRITE, ulog, repo -> { final Collection pruned = repo.doLocalPrune(ulog); if (pruned != null) { ulog.message(UserMessage.localized("fastback.chat.prune-done", pruned.size())); if (!pruned.isEmpty()) ulog.message(UserMessage.localized("fastback.chat.prune-suggest-gc")); } }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/PushCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.SnapshotId; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.getArgumentNicely; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; /** * @author pcal * @since 0.15.0 */ enum PushCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "push"; private static final String ARGUMENT = "snapshot-date"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then(literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)).then( argument(ARGUMENT, StringArgumentType.string()). suggests(SnapshotNameSuggestions.local()). executes(PushCommand::execute) ) ); } private static int execute(CommandContext cc) { final UserLogger log = UserLogger.ulog(cc); gitOp(NONE, log, repo -> { final String snapshotName = getArgumentNicely(ARGUMENT, String.class, cc.getLastChild(), log); final SnapshotId sid = repo.createSnapshotId(snapshotName); repo.doPushSnapshot(sid, log); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/RemoteDeleteCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; enum RemoteDeleteCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "remote-delete"; private static final String ARGUMENT = "snapshot"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then(literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)).then( argument(ARGUMENT, StringArgumentType.string()). suggests(SnapshotNameSuggestions.remote()). executes(RemoteDeleteCommand::delete) ) ); } private static int delete(CommandContext cc) { final UserLogger log = ulog(cc); gitOp(WRITE, log, repo -> { final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class); final SnapshotId sid = repo.createSnapshotId(snapshotName); repo.deleteRemoteBranch(sid.getBranchName()); log.message(UserMessage.localized("fastback.chat.remote-delete-done", snapshotName)); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/RemoteListCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; enum RemoteListCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "remote-list"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(RemoteListCommand::execute) ); } private static int execute(final CommandContext cc) { final UserLogger log = UserLogger.ulog(cc); gitOp(NONE, log, repo -> { final List snapshots = new ArrayList<>(repo.getRemoteSnapshots()); Collections.sort(snapshots); snapshots.forEach(sid -> log.message(UserMessage.raw(sid.getShortName()))); log.message(UserMessage.localized("fastback.chat.remote-list-done", snapshots.size(), repo.getConfig().getString(REMOTE_PUSH_URL))); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/RemotePruneCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.Collection; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.WRITE; /** * Command to prune all snapshots that are not to be retained per the retention policy. * * @author pcal * @since 0.2.0 */ enum RemotePruneCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "remote-prune"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> remotePrune(cc.getSource())) ); } private static int remotePrune(final CommandSourceStack scs) { final UserLogger ulog = ulog(scs); gitOp(WRITE, ulog, repo -> { final Collection pruned = repo.doRemotePrune(ulog); ulog.message(UserMessage.localized("fastback.chat.prune-done", pruned.size())); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/RemoteRestoreCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; enum RemoteRestoreCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "remote-restore"; private static final String ARGUMENT = "snapshot"; @Override public void register(final LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)).then( argument(ARGUMENT, StringArgumentType.string()). suggests(SnapshotNameSuggestions.remote()). executes(RemoteRestoreCommand::remoteRestore) ) ); } private static int remoteRestore(final CommandContext cc) { final UserLogger ulog = ulog(cc); gitOp(NONE, ulog, repo -> { final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class); repo.doRestoreRemoteSnapshot(snapshotName, ulog); }); return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/RestoreCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; enum RestoreCommand implements Command { INSTANCE; private static final String COMMAND_NAME = "restore"; private static final String ARGUMENT = "snapshot"; @Override public void register(LiteralArgumentBuilder argb, PermissionsFactory pf) { argb.then( literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)).then( argument(ARGUMENT, StringArgumentType.string()). suggests(SnapshotNameSuggestions.local()). executes(RestoreCommand::restore) ) ); } private static int restore(final CommandContext cc) { try (final UserLogger ulog = UserLogger.ulog(cc)) { gitOp(NONE, ulog, repo -> { final String snapshotName = cc.getLastChild().getArgument(ARGUMENT, String.class); repo.doRestoreLocalSnapshot(snapshotName, ulog); }); } return SUCCESS; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/SchedulableAction.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import net.pcal.fastback.common.config.FastbackConfigKey; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.Repo; import java.util.concurrent.Callable; import static java.util.Objects.requireNonNull; /** * Encapsulates an action that can be performed in response to events such as shutdown or autosaving. * * @author pcal * @since 0.2.0 */ public enum SchedulableAction { NONE("none") { @Override public Callable getTask(final Repo repo, final UserLogger ulog) { return () -> null; } }, LOCAL("local") { @Override public Callable getTask(final Repo repo, final UserLogger ulog) { return () -> { repo.doCommitSnapshot(ulog); return null; }; } }, FULL("full") { @Override public Callable getTask(final Repo repo, final UserLogger ulog) { return () -> { repo.doCommitAndPush(ulog); return null; }; } }, FULL_GC("full-gc") { @Override public Callable getTask(final Repo repo, final UserLogger ulog) { return () -> { repo.doCommitAndPush(ulog); repo.doLocalPrune(ulog); repo.doGc(ulog); return null; }; } }; public static SchedulableAction forConfigValue(final GitConfig c, final FastbackConfigKey key) { String configValue = c.getString(key); if (configValue == null) return null; return forConfigValue(configValue); } public static SchedulableAction forConfigValue(String configValue) { if (configValue == null) return null; for (SchedulableAction action : SchedulableAction.values()) { if (action.configValue.equals(configValue)) { return action == SchedulableAction.NONE ? null : action; } } return null; } private final String configValue; SchedulableAction(String configValue) { this.configValue = requireNonNull(configValue); } public String getConfigValue() { return this.configValue; } public String getArgumentName() { return this.configValue; } public abstract Callable getTask(Repo repo, UserLogger ulog); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/SetCommand.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.config.FastbackConfigKey; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.config.GitConfigKey; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.repo.RepoFactory; import net.pcal.fastback.common.retention.RetentionPolicy; import net.pcal.fastback.common.retention.RetentionPolicyCodec; import net.pcal.fastback.common.retention.RetentionPolicyType; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; import static net.pcal.fastback.common.commands.Commands.FAILURE; import static net.pcal.fastback.common.commands.Commands.SUCCESS; import static net.pcal.fastback.common.commands.Commands.getArgumentNicely; import static net.pcal.fastback.common.commands.Commands.missingArgument; import static net.pcal.fastback.common.commands.Commands.subcommandPermission; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_LOCK_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY; import static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_RETENTION_POLICY; import static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY; import static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserLogger.ulog; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.logging.UserMessage.raw; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoFactory.rf; /** * Sets various configuration values. * * @author pcal * @since 0.13.0 */ enum SetCommand implements Command { INSTANCE; // ====================================================================== // Command implementation private static final String COMMAND_NAME = "set"; @Override public void register(final LiteralArgumentBuilder root, PermissionsFactory pf) { final LiteralArgumentBuilder sc = literal(COMMAND_NAME). requires(subcommandPermission(COMMAND_NAME, pf)). executes(cc -> missingArgument("key", cc)); registerBooleanConfigValue(IS_LOCK_CLEANUP_ENABLED, sc); registerBooleanConfigValue(IS_BACKUP_ENABLED, sc); registerBooleanConfigValue(IS_MODS_BACKUP_ENABLED, sc); registerBooleanConfigValue(BROADCAST_ENABLED, sc); registerStringConfigValue(BROADCAST_MESSAGE, "message", sc); registerStringConfigValue(RESTORE_DIRECTORY, "full-directory-path", sc); registerStringConfigValue(REMOTE_PUSH_URL, "url", sc); registerIntegerConfigValue(AUTOBACK_WAIT_MINUTES, "minutes", sc); { final List schedulableActions = new ArrayList<>(); for (final SchedulableAction sa : SchedulableAction.values()) { schedulableActions.add(sa.getConfigValue()); } registerSelectConfigValue(AUTOBACK_ACTION, schedulableActions, sc); registerSelectConfigValue(SHUTDOWN_ACTION, schedulableActions, sc); } registerSetRetentionCommand(LOCAL_RETENTION_POLICY, sc); registerSetRetentionCommand(REMOTE_RETENTION_POLICY, sc); registerForceDebug(sc); root.then(sc); } // ====================================================================== // Boolean config values private static void registerBooleanConfigValue(final GitConfigKey key, final LiteralArgumentBuilder setCommand) { final LiteralArgumentBuilder builder = literal(key.getDisplayName()); builder.then(literal("true").executes(cc -> setBooleanConfigValue(key, true, cc))); builder.then(literal("false").executes(cc -> setBooleanConfigValue(key, false, cc))); setCommand.then(builder); } private static int setBooleanConfigValue(final GitConfigKey key, final boolean newValue, final CommandContext cc) { try (UserLogger ulog = ulog(cc)) { final Path worldSaveDir = mod().getWorldDirectory(); final RepoFactory rf = rf(); if (rf.isGitRepo(worldSaveDir)) { try (Repo repo = rf.load(worldSaveDir)) { final GitConfig conf = repo.getConfig(); boolean current = conf.getBoolean(key); if (current == newValue) { ulog.message(localized("fastback.chat.no-change")); } else { repo.getConfig().updater().set(key, newValue).save(); ulog.message(raw(key.getDisplayName() + " = " + newValue)); } } catch (Exception e) { ulog.internalError(e); return FAILURE; } } } return SUCCESS; } // ====================================================================== // Integer config values private static void registerIntegerConfigValue(final GitConfigKey key, final String argName, final LiteralArgumentBuilder setCommand) { final LiteralArgumentBuilder builder = literal(key.getDisplayName()); builder.then(argument(argName, IntegerArgumentType.integer()). executes(cc -> setIntegerConfigValue(key, argName, cc))); setCommand.then(builder); } private static int setIntegerConfigValue(final GitConfigKey key, final String argName, final CommandContext cc) { try (UserLogger ulog = ulog(cc)) { final Path worldSaveDir = mod().getWorldDirectory(); final RepoFactory rf = rf(); if (rf.isGitRepo(worldSaveDir)) { try (Repo repo = rf.load(worldSaveDir)) { final Integer newValue = cc.getArgument(argName, Integer.class); repo.getConfig().updater().set(key, newValue).save(); ulog.message(raw(key.getDisplayName() + " = " + newValue)); } catch (Exception e) { ulog.internalError(e); return FAILURE; } } } return SUCCESS; } // ====================================================================== // String config values private static void registerStringConfigValue(final GitConfigKey key, final String argName, final LiteralArgumentBuilder setCommand) { final LiteralArgumentBuilder builder = literal(key.getDisplayName()); builder.then(argument(argName, StringArgumentType.greedyString()). executes(cc -> setStringConfigValue(key, argName, cc))); setCommand.then(builder); } private static int setStringConfigValue(final GitConfigKey key, final String argName, final CommandContext cc) { try (UserLogger ulog = ulog(cc)) { final Path worldSaveDir = mod().getWorldDirectory(); final RepoFactory rf = rf(); if (rf.isGitRepo(worldSaveDir)) { try (Repo repo = rf.load(worldSaveDir)) { final String newValue = cc.getArgument(argName, String.class); repo.getConfig().updater().set(key, newValue).save(); ulog.message(raw(key.getDisplayName() + " = " + newValue)); } catch (Exception e) { ulog.internalError(e); return FAILURE; } } } return SUCCESS; } // ====================================================================== // Selection config values private static void registerSelectConfigValue(GitConfigKey key, List selections, final LiteralArgumentBuilder setCommand) { final LiteralArgumentBuilder builder = literal(key.getDisplayName()); for (final String selection : selections) { builder.then(literal(selection).executes(cc -> setSelectionConfigValue(key, selection, cc))); } setCommand.then(builder); } private static int setSelectionConfigValue(final GitConfigKey key, final String newValue, final CommandContext cc) { try (UserLogger ulog = ulog(cc)) { final Path worldSaveDir = mod().getWorldDirectory(); if (rf().isGitRepo(worldSaveDir)) { try (final Repo repo = rf().load(worldSaveDir)) { repo.getConfig().updater().set(key, newValue).save(); ulog.message(raw(key.getDisplayName() + " = " + newValue)); } catch (Exception e) { ulog.internalError(e); return FAILURE; } } } return SUCCESS; } // ====================================================================== // force-debug private static final String FORCE_DEBUG_SETTING = "force-debug-enabled"; private static void registerForceDebug(final LiteralArgumentBuilder setCommand) { final LiteralArgumentBuilder debug = literal(FORCE_DEBUG_SETTING); debug.then(literal("true").executes(cc -> setForceDebug(cc, true))); debug.then(literal("false").executes(cc -> setForceDebug(cc, false))); setCommand.then(debug); } private static int setForceDebug(final CommandContext cc, boolean value) { syslog().setForceDebugEnabled(value); try (final UserLogger ulog = ulog(cc)) { ulog.message(raw("force-debug-enabled = " + value)); } return SUCCESS; } // ====================================================================== // Retention policy commands /** * Register a 'set retention' command that tab completes with all the policies and the policy arguments. * Broken out as a helper methods so this logic can be shared by set-retention and set-remote-retention. *

* FIXME? The command parsing here could be more user-friendly. Not really clear how to implement * argument defaults. Also a lot of noise from bugs like this: https://bugs.mojang.com/browse/MC-165562 * Just generally not sure how to beat brigadier into submission here. */ private static void registerSetRetentionCommand(final FastbackConfigKey key, final LiteralArgumentBuilder argb) { final LiteralArgumentBuilder retainCommand = literal(key.getSettingName()); for (final RetentionPolicyType rpt : RetentionPolicyType.getAvailable()) { final LiteralArgumentBuilder policyCommand = literal(rpt.getCommandName()); policyCommand.executes(cc -> setRetentionPolicy(cc, rpt, key)); if (rpt.getParameters() != null) { for (RetentionPolicyType.Parameter param : rpt.getParameters()) { policyCommand.then(argument(param.name(), param.type()). executes(cc -> setRetentionPolicy(cc, rpt, key))); } } retainCommand.then(policyCommand); } argb.then(retainCommand); } /** * Does the work to encode a policy configuration and set it in git configuration. * Broken out as a helper methods so this logic can be shared by set-retention and set-remote-retention. *

* TODO this should probably move to Repo. */ public static int setRetentionPolicy(final CommandContext cc, final RetentionPolicyType rpt, final FastbackConfigKey confKey) { final UserLogger ulog = ulog(cc); final Path worldSaveDir = mod().getWorldDirectory(); try (final Repo repo = rf().load(worldSaveDir)) { final Map config = new HashMap<>(); for (final RetentionPolicyType.Parameter p : rpt.getParameters()) { final Object val = getArgumentNicely(p.name(), p.clazz(), cc, ulog); if (val == null) return FAILURE; config.put(p.name(), String.valueOf(val)); } final String encodedPolicy = RetentionPolicyCodec.INSTANCE.encodePolicy(rpt, config); final RetentionPolicy rp = RetentionPolicyCodec.INSTANCE.decodePolicy(RetentionPolicyType.getAvailable(), encodedPolicy); if (rp == null) { syslog().error("Failed to decode policy " + encodedPolicy, new Exception()); return FAILURE; } final GitConfig conf = repo.getConfig(); conf.updater().set(confKey, encodedPolicy).save(); ulog.message(localized("fastback.chat.retention-policy-set")); ulog.message(rp.getDescription()); return SUCCESS; } catch (Exception e) { syslog().error("Failed to set retention policy", e); return FAILURE; } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/commands/SnapshotNameSuggestions.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.commands; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.repo.SnapshotId; import java.util.Iterator; import java.util.concurrent.CompletableFuture; import static net.pcal.fastback.common.commands.Commands.gitOp; import static net.pcal.fastback.common.utils.Executor.ExecutionLock.NONE; abstract class SnapshotNameSuggestions implements SuggestionProvider { static SnapshotNameSuggestions local() { return new SnapshotNameSuggestions() { @Override protected Iterator getSnapshotIds(Repo repo, UserLogger ulog) throws Exception { return repo.getLocalSnapshots().iterator(); } }; } static SnapshotNameSuggestions remote() { return new SnapshotNameSuggestions() { @Override protected Iterator getSnapshotIds(Repo repo, UserLogger ulog) throws Exception { return repo.getRemoteSnapshots().iterator(); } }; } @Override public CompletableFuture getSuggestions(final CommandContext cc, final SuggestionsBuilder builder) { CompletableFuture completableFuture = new CompletableFuture<>(); try (final UserLogger ulog = UserLogger.ulog(cc)) { gitOp(NONE, ulog, repo -> { final Iterator i = getSnapshotIds(repo, ulog); // Note to self: there's no point sorting here because the mc code (Suggestion.java) is // going to resort it anyway. while (i.hasNext()) builder.suggest(i.next().getShortName()); completableFuture.complete(builder.buildFuture().get()); }); } return completableFuture; } abstract protected Iterator getSnapshotIds(Repo repo, UserLogger log) throws Exception; } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/config/FastbackConfigKey.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.config; /** * .gitconfig settings that fastback cares about. * * @author pcal */ public enum FastbackConfigKey implements GitConfigKey { AUTOBACK_ACTION("autoback-action", null), AUTOBACK_WAIT_MINUTES("autoback-wait", 0), BROADCAST_ENABLED("broadcast-enabled", true), BROADCAST_MESSAGE("broadcast-message", null), IS_BACKUP_ENABLED("backup-enabled", true), IS_BRANCH_CLEANUP_ENABLED(true), IS_FILE_REMOTE_BARE(true), IS_LOCK_CLEANUP_ENABLED("lock-cleanup-enabled", true), IS_NATIVE_GIT_ENABLED("native-git-enabled", true), IS_MODS_BACKUP_ENABLED("mods-backup-enabled", false), IS_REFLOG_DELETION_ENABLED(true), IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED(true), IS_SMART_PUSH_ENABLED("smart-push-enabled", false), IS_TEMP_BRANCH_CLEANUP_ENABLED(true), IS_TRACKING_BRANCH_CLEANUP_ENABLED(true), IS_UUID_CHECK_ENABLED(true), LOCAL_RETENTION_POLICY("retention-policy", null), REMOTE_NAME("remote-name", "origin"), REMOTE_RETENTION_POLICY("remote-retention-policy", null), RESTORE_DIRECTORY("restore-directory", null), SHUTDOWN_ACTION("shutdown-action", "local"), UPDATE_GITATTRIBUTES_ENABLED("update-gitattributes-enabled", true), UPDATE_GITIGNORE_ENABLED("update-gitignore-enabled", true); private final String settingName; private final Boolean booleanDefault; private final String stringDefault; private final Integer intDefault; FastbackConfigKey(boolean booleanDefaultValue) { this(null, booleanDefaultValue, null, null); } FastbackConfigKey(final String settingName, boolean booleanDefaultValue) { this(settingName, booleanDefaultValue, null, null); } FastbackConfigKey(final String settingName, String stringDefaultValue) { this(settingName, null, stringDefaultValue, null); } FastbackConfigKey(final String settingName, int intDefault) { this(settingName, null, null, intDefault); } FastbackConfigKey(final String settingName, final Boolean booleanDefault, String stringDefault, Integer intDefault) { this.settingName = settingName; //requireNonNull(settingName); this.booleanDefault = booleanDefault; this.stringDefault = stringDefault; this.intDefault = intDefault; } @Override public String getSectionName() { return "fastback"; } @Override public String getSubSectionName() { return null; } @Override public String getSettingName() { return this.settingName; } @Override public boolean getBooleanDefault() { if (this.booleanDefault == null) throw new IllegalStateException(this + " is not a boolean"); return this.booleanDefault; } @Override public String getStringDefault() { return this.stringDefault; } @Override public int getIntDefault() { if (this.intDefault == null) throw new IllegalStateException(this + " is not an int"); return this.intDefault; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/config/GitConfig.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.config; import org.eclipse.jgit.api.Git; import java.io.IOException; /** * Abstract representation of a git worktree's configuration. * * @author pcal */ public interface GitConfig { // @Deprecated - eventually we want the public contract to stop being // coupled to jgit static GitConfig load(Git jgit) { return GitConfigImpl.load(jgit); } boolean getBoolean(GitConfigKey key); String getString(GitConfigKey key); int getInt(GitConfigKey key); boolean isSet(GitConfigKey key); Updater updater(); /** * Helper for updating the local .git/config file. */ interface Updater { Updater set(GitConfigKey key, boolean newValue); Updater set(GitConfigKey key, String newValue); Updater set(GitConfigKey key, int newValue); Updater setCommented(GitConfigKey key, boolean newValue); Updater setCommented(GitConfigKey key, String newValue); Updater setCommented(GitConfigKey key, int newValue); void save() throws IOException; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/config/GitConfigImpl.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.config; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.StoredConfig; import java.io.IOException; import static java.util.Objects.requireNonNull; /** * JGit-based implementation of GitConfig. * * @author pcal */ class GitConfigImpl implements GitConfig { static GitConfig load(final Git jgit) { return new GitConfigImpl(jgit.getRepository().getConfig()); } public final StoredConfig storedConfig; GitConfigImpl(StoredConfig jgitConfig) { this.storedConfig = requireNonNull(jgitConfig); } @Override public boolean getBoolean(GitConfigKey key) { if (key.getSettingName() == null) return key.getBooleanDefault(); return storedConfig.getBoolean(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), key.getBooleanDefault()); } @Override public String getString(GitConfigKey key) { if (key.getSettingName() == null) return key.getStringDefault(); final String out = storedConfig.getString(key.getSectionName(), key.getSubSectionName(), key.getSettingName()); return out != null ? out : key.getStringDefault(); } @Override public int getInt(GitConfigKey key) { if (key.getSettingName() == null) return key.getIntDefault(); return storedConfig.getInt(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), key.getIntDefault()); } @Override public boolean isSet(GitConfigKey key) { final String out = storedConfig.getString(key.getSectionName(), key.getSubSectionName(), key.getSettingName()); return out != null; } @Override public Updater updater() { return new UpdaterImpl(); } private class UpdaterImpl implements Updater { @Override public Updater set(GitConfigKey key, boolean newValue) { storedConfig.setBoolean(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue); return this; } @Override public Updater set(GitConfigKey key, String newValue) { storedConfig.setString(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue); return this; } @Override public Updater set(GitConfigKey key, int newValue) { storedConfig.setInt(key.getSectionName(), key.getSubSectionName(), key.getSettingName(), newValue); return this; } // ====================================================================== // Methods for adding commented-out settings. Useful for making the // initial git config a little more self-documenting. jgit evidently // doesn't know the difference. @Override public Updater setCommented(GitConfigKey key, boolean newValue) { storedConfig.setBoolean(key.getSectionName(), key.getSubSectionName(), "# " + key.getSettingName(), newValue); return this; } @Override public Updater setCommented(GitConfigKey key, String newValue) { storedConfig.setString(key.getSectionName(), key.getSubSectionName(), "# " + key.getSettingName(), newValue); return this; } @Override public Updater setCommented(GitConfigKey key, int newValue) { storedConfig.setInt(key.getSectionName(), key.getSubSectionName(), "# " + key.getSettingName(), newValue); return this; } @Override public void save() throws IOException { storedConfig.save(); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/config/GitConfigKey.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.config; /** * @author pcal * @since 0.14.0 */ public interface GitConfigKey { String getSectionName(); String getSubSectionName(); String getSettingName(); boolean getBooleanDefault(); String getStringDefault(); default String getDisplayName() { return this.getSettingName(); } int getIntDefault(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/config/OtherConfigKey.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.config; import static java.util.Objects.requireNonNull; /** * .gitconfig settings that fastback cares about. * * @author pcal */ public enum OtherConfigKey implements GitConfigKey { REMOTE_PUSH_URL("remote", "origin", "url") { @Override public String getDisplayName() { return "remote-url"; } }, /** * We disable commit signing on git init. https://github.com/pcal43/fastback/issues/165 */ COMMIT_SIGNING_ENABLED("commit", null, "gpgsign"), // Allow reading the user's name and email from .gitconfig USER_NAME("user", null, "name"), USER_EMAIL("user", null, "email"); private final String sectionName, subSectionName, settingName; OtherConfigKey(final String sectionName, final String subsectionName, final String settingName) { this.sectionName = requireNonNull(sectionName); this.subSectionName = subsectionName; this.settingName = requireNonNull(settingName); } @Override public String getSectionName() { return this.sectionName; } @Override public String getSubSectionName() { return this.subSectionName; } @Override public String getSettingName() { return this.settingName; } @Override public boolean getBooleanDefault() { return false; } @Override public String getStringDefault() { return null; } @Override public int getIntDefault() { return 0; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/AutosaveLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import static net.pcal.fastback.common.mod.Mod.mod; /** * Handles messages in the context of an autosave operation. * * @author pcal * @since 0.15.0 */ enum AutosaveLogger implements UserLogger { INSTANCE; @Override public void message(final UserMessage message) { } @Override public void update(final UserMessage message) { mod().setHudText(message); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/CommandLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import net.minecraft.commands.CommandSourceStack; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.mod.Mod.mod; /** * Handles messages in the context of a command executed by the user in the console or chat box. * * @author pcal * @since 0.15.0 */ class CommandLogger implements UserLogger { private final CommandSourceStack scs; CommandLogger(final CommandSourceStack scs) { this.scs = requireNonNull(scs); } @Override public void message(final UserMessage message) { mod().sendChat(message, this.scs); } @Override public void update(final UserMessage message) { mod().setHudText(message); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/Log4jLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import net.pcal.fastback.common.utils.ProcessException; import static java.util.Objects.requireNonNull; public class Log4jLogger implements SystemLogger { private final org.apache.logging.log4j.Logger log4j; private boolean forceDebugEnabled = false; public Log4jLogger(org.apache.logging.log4j.Logger log4j) { this.log4j = requireNonNull(log4j); } @Override public void setForceDebugEnabled(boolean forceDebugEnabled) { this.forceDebugEnabled = forceDebugEnabled; } @Override public void error(String message) { this.log4j.error(message); } @Override public void error(String message, Throwable t) { // In the case of process execution failure, ensure we always dump the output along with the stacktrace so // we have a prayer of understanding what actually went wrong if (t instanceof ProcessException pe) pe.writeProcessOutput(this::error); this.log4j.error(message, t); } @Override public void warn(String message) { this.log4j.warn(message); } @Override public void info(String message) { this.log4j.info(message); } @Override public void debug(String message) { if (this.forceDebugEnabled) { this.log4j.info("[DEBUG] " + message); } else { this.log4j.debug(message); } } @Override public void debug(String message, Throwable t) { if (this.forceDebugEnabled) { // In the case of process execution failure, ensure we always dump the output along with the stacktrace so // we have a prayer of understanding what actually went wrong if (t instanceof ProcessException pe) pe.writeProcessOutput(line -> this.log4j.info("[DEBUG] " + message)); this.log4j.info("[DEBUG] " + message, t); } else { this.log4j.debug(message, t); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/ShutdownLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import static net.pcal.fastback.common.mod.Mod.mod; /** * Handles messages in the context of the server shutting down. * * @author pcal * @since 0.15.0 */ enum ShutdownLogger implements UserLogger { INSTANCE; @Override public void message(final UserMessage message) { mod().setMessageScreenText(message); } @Override public void update(final UserMessage message) { mod().setHudText(message); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/SystemLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import java.util.function.Supplier; /** * Singleton logger instance that writes to the serverside console. * * @author pcal * @since 0.12.0 */ public interface SystemLogger { static SystemLogger syslog() { return Singleton.INSTANCE; } void setForceDebugEnabled(boolean debug); void error(String message); void error(String message, Throwable t); default void error(Throwable e) { this.error(e.getMessage(), e); } void warn(String message); void info(String message); void debug(String message); default void trace(Supplier message) { debug(message.get()); //FIXME } void debug(String message, Throwable t); default void debug(Throwable t) { this.debug(t.getMessage(), t); } class Singleton { private static SystemLogger INSTANCE = null; public static void register(SystemLogger logger) { Singleton.INSTANCE = logger; } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/UserLogger.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import com.mojang.brigadier.context.CommandContext; import net.minecraft.commands.CommandSourceStack; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.mod.Mod.mod; /** * Logging interface for messages which *might* be displayed in the UI. * * @author pcal * @since 0.13.0 */ public interface UserLogger extends AutoCloseable { /** * Send a fairly important message that should be displayed in the UI a relatively prominent and durable manner. * Typically, this means in the chat dialog. */ void message(UserMessage message); /** * Send a bit of low-level detail that is useful for indicating progress or activity but isn't of critical * importance. This will typically be displayed in the HUD area. */ void update(UserMessage message); @Override default void close() { mod().clearHudText(); } default void internalError() { this.message(styledLocalized("fastback.chat.internal-error", ERROR)); } default void internalError(Exception e) { syslog().error(e); internalError(); } static UserLogger ulog(final CommandContext cc) { return new CommandLogger(cc.getSource()); } static UserLogger ulog(final CommandSourceStack scs) { return new CommandLogger(scs); } static UserLogger forShutdown() { return ShutdownLogger.INSTANCE; } static UserLogger forAutosave() { return ShutdownLogger.INSTANCE; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/logging/UserMessage.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.logging; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL; /** * Abstract representation of a message to be displayed on the user's screen. The message may or may * not be localizable. * * @author pcal */ public record UserMessage(LocalizedUserMessage localized, String raw, UserMessageStyle style) { public enum UserMessageStyle { NORMAL, WARNING, ERROR, JGIT, NATIVE_GIT, BROADCAST, } public record LocalizedUserMessage(String key, Object... params) { @Override public String toString() { return this.key + " " + (this.params != null ? params : "[]"); } } public static UserMessage localized(String key, Object... params) { return styledLocalized(key, NORMAL, params); } public static UserMessage styledLocalized(String key, UserMessageStyle style, Object... params) { return new UserMessage(new LocalizedUserMessage(key, params), null, style); } public static UserMessage raw(String text) { return styledRaw(text, NORMAL); } public static UserMessage styledRaw(String text, UserMessageStyle style) { return new UserMessage(null, requireNonNull(text), style); } @Override public String toString() { return this.raw != null ? raw : this.localized != null ? this.localized.toString() : null; } // ====================================================================== // Deprecated stuff @Deprecated public static UserMessage localizedError(String key, Object... params) { return styledLocalized(key, ERROR, params); } @Deprecated public static UserMessage rawError(String text) { return styledRaw(text, ERROR); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/FileFixerUpperMixin.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.util.filefix.FileFixerUpper; import net.minecraft.util.filefix.virtualfilesystem.CopyOnWriteFileSystem; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; import java.io.IOException; import java.nio.file.Path; import java.nio.file.PathMatcher; /** * Prevents MC 26.1+'s CopyOnWriteFileSystem (used during world upgrade) from choking * on read-only git object files inside the .git directory. * * @author pcal * @since 0.31.2 */ @Mixin(FileFixerUpper.class) public class FileFixerUpperMixin { @Redirect( method = "applyFileFixersOnCow", at = @At( value = "INVOKE", 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;", remap = false ), remap = false ) private CopyOnWriteFileSystem fastback_skipGitDir( String name, Path baseDir, Path tmpDir, PathMatcher original) throws IOException { PathMatcher withGitSkip = path -> { for (Path component : path) { if (".git".equals(component.toString())) return true; } return original.matches(path); }; return CopyOnWriteFileSystem.create(name, baseDir, tmpDir, withGitSkip); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/MessageScreenMixin.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.GenericMessageScreen; import net.pcal.fastback.common.mod.Mod; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** * Implements a callback that lets us render extra text on MessageScreens (i.e., exit/saving screen). * * @author pcal * @since 0.14.0 */ @Mixin(GenericMessageScreen.class) public class MessageScreenMixin { @Inject(method = "extractBackground", at = @At("TAIL"), remap = false) public void fastback_render(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta, CallbackInfo ci) { Mod.mod().renderMessageScreen(context); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/MinecraftServerMixin.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.server.MinecraftServer; import net.pcal.fastback.common.mod.Mod; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Allows us to disable vanilla saving during 'git add' to avoid coherency problems in the backup snapshots. Also * sends notifications when autosaving completes so we can follow them with automated backups. * * @author pcal * @since 0.0.1 */ @Mixin(MinecraftServer.class) public class MinecraftServerMixin { /** * Intercept the call to saveAll that triggers on autosave, pass it through and then send out notification that * the autosave is done. */ @Redirect(method = "autoSave()V", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;saveEverything(ZZZ)Z"), remap = false) public boolean fastback_saveAll(MinecraftServer instance, boolean suppressLogs, boolean flush, boolean force) { boolean result = instance.saveEverything(suppressLogs, flush, force); Mod.mod().autoSaveCompleted(); return result; } /** * Intercept save so we can hard-disable saving during critical parts of the backup. */ @Inject(at = @At("HEAD"), method = "saveAllChunks(ZZZ)Z", cancellable = true, remap = false) public void fastback_save(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable ci) { synchronized (this) { if (Mod.mod().isWorldSaveEnabled()) { syslog().debug("world saves are enabled, doing requested save"); } else { syslog().warn("Skipping requested save because a backup is in progress."); ci.setReturnValue(false); ci.cancel(); } } } /** * Intercept saveAll so we can hard-disable saving during critical parts of the backup. */ @Inject(at = @At("HEAD"), method = "saveEverything(ZZZ)Z", cancellable = true, remap = false) public void fastback_saveAll(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable ci) { synchronized (this) { if (Mod.mod().isWorldSaveEnabled()) { syslog().debug("world saves are enabled, doing requested saveAll"); //TODO should call save here to ensure all synced? } else { syslog().warn("Skipping requested saveAll because a backup is in progress."); ci.setReturnValue(false); ci.cancel(); } } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/ScreenAccessors.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mutable; import org.spongepowered.asm.mixin.gen.Accessor; import org.spongepowered.asm.mixin.gen.Invoker; /** * @author pcal * @since 0.0.11 */ @Mixin(Screen.class) public interface ScreenAccessors { @Accessor(remap = false) @Mutable Component getTitle(); @Invoker(remap = false) @Mutable void invokeRebuildWidgets(); @Accessor(remap = false) @Mutable void setTitle(Component text); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/ServerAccessors.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.storage.LevelStorageSource; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; /** * @author pcal * @since 0.0.1 */ @Mixin(MinecraftServer.class) public interface ServerAccessors { @Accessor(remap = false) int getTickCount(); @Accessor(remap = false) LevelStorageSource.LevelStorageAccess getStorageSource(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mixins/SessionAccessors.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mixins; import net.minecraft.world.level.storage.LevelStorageSource; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; /** * @author pcal * @since 0.0.1 */ @Mixin(LevelStorageSource.LevelStorageAccess.class) public interface SessionAccessors { @Accessor(remap = false) LevelStorageSource.LevelDirectory getLevelDirectory(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/AutosaveListener.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import net.pcal.fastback.common.commands.SchedulableAction; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.repo.RepoFactory; import net.pcal.fastback.common.utils.Executor; import java.nio.file.Path; import java.time.Duration; import static net.pcal.fastback.common.commands.SchedulableAction.NONE; import static net.pcal.fastback.common.commands.SchedulableAction.forConfigValue; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_ACTION; import static net.pcal.fastback.common.config.FastbackConfigKey.AUTOBACK_WAIT_MINUTES; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.utils.Executor.executor; /** * Responds to vanilla autosaves and follows them with an automatic backup (autoback). * * @author pcal * @since 0.2.0 */ class AutosaveListener implements Runnable { private long lastBackupTime = System.currentTimeMillis(); @Override public void run() { try (final UserLogger ulog = UserLogger.forAutosave()) { executor().execute(Executor.ExecutionLock.WRITE, ulog, () -> { try { final RepoFactory rf = RepoFactory.rf(); final Path worldSaveDir = mod().getWorldDirectory(); if (!rf.isGitRepo(worldSaveDir)) return; try (final Repo repo = rf.load(worldSaveDir)) { final GitConfig config = repo.getConfig(); if (!config.getBoolean(IS_BACKUP_ENABLED)) return; final SchedulableAction autobackAction = forConfigValue(config, AUTOBACK_ACTION); if (autobackAction == null || autobackAction == NONE) return; final Duration waitTime = Duration.ofMinutes(config.getInt(AUTOBACK_WAIT_MINUTES)); final Duration timeRemaining = waitTime. minus(Duration.ofMillis(System.currentTimeMillis() - lastBackupTime)); if (!timeRemaining.isZero() && !timeRemaining.isNegative()) { syslog().debug("Skipping auto-backup until at least " + (timeRemaining.toSeconds() / 60) + " more minutes have elapsed."); return; } syslog().info("Starting auto-backup"); autobackAction.getTask(repo, ulog).call(); } lastBackupTime = System.currentTimeMillis(); } catch (Exception e) { syslog().error("auto-backup failed.", e); } }); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/ClientHelper.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.GenericMessageScreen; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.mixins.ScreenAccessors; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.mod.UserMessageUtil.messageToText; /** * Client-only helper services. Holds vanilla Minecraft client state and provides * concrete implementations for HUD and message screen management. * * @author pcal * @since 0.2.0 */ public final class ClientHelper { // ====================================================================== // Constants private static final long TEXT_TIMEOUT = 10 * 1000; // ====================================================================== // Fields private final Minecraft client; private Component hudText; private long hudTextTime; // ====================================================================== // Constructor public ClientHelper(Minecraft client) { this.client = client; } // ====================================================================== // Concrete — vanilla Minecraft implementations public void setHudText(UserMessage userMessage) { if (userMessage == null) { clearHudText(); } else { this.hudText = messageToText(userMessage); this.hudTextTime = System.currentTimeMillis(); } } public void clearHudText() { this.hudText = null; } public void setMessageScreenText(UserMessage userMessage) { if (this.client == null) return; final Screen screen = client.screen; if (screen instanceof GenericMessageScreen) { ((ScreenAccessors) screen).setTitle(messageToText(userMessage)); ((ScreenAccessors) screen).invokeRebuildWidgets(); // force it to rebuild the message component with the new title } } public void renderMessageScreen(GuiGraphicsExtractor guiGraphics) { renderHud(guiGraphics); } public void renderHud(GuiGraphicsExtractor guiGraphics) { if (this.client == null) return; if (this.hudText == null) return; if (!this.client.options.showAutosaveIndicator().get()) return; if (System.currentTimeMillis() - this.hudTextTime > TEXT_TIMEOUT) { this.hudText = null; syslog().debug("hud text timed out. somebody forgot to clean up"); return; } guiGraphics.text(this.client.font, this.hudText, 2, 2, 0xFFFFFF, false); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/LoaderHelper.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.commands.PermissionsFactory; import java.nio.file.Path; import java.util.Collection; import java.util.Map; import java.util.function.Function; /** * Abstracts away loader/environment-specific services that the mod framework * (e.g. Fabric) must provide. * * @author pcal * @since 0.2.0 */ public interface LoaderHelper { /** * @return the version string of the fastback mod as reported by the loader. */ String getModVersion(); /** * Appends loader-specific properties (e.g. the installed mod list) to the backup * properties map. Common minecraft-* and fastback-version entries are added by ModImpl. */ void addLoaderBackupProperties(Map props); /** * @return path to the client 'saves' directory, or null on a dedicated server. */ Path getSavesDir(); /** * @return paths that should be included when mods-backup is enabled. */ Collection getModsBackupPaths(); /** * Create the /backup command using the given builder and register it. * * @param isClient true when running on an integrated (client-embedded) server. */ void registerBackupCommand(boolean isClient, Function, LiteralArgumentBuilder> builder); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/Mod.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.MinecraftServer; import net.pcal.fastback.common.logging.UserMessage; import java.io.IOException; import java.nio.file.Path; import java.util.Collection; import java.util.Map; import static java.util.Objects.requireNonNull; /** * Singleton that provides various mod-wide services. * * @author pcal * @since 0.1.0 */ public interface Mod { // ====================================================================== // Singleton static Mod mod() { return SingletonHolder.INSTANCE; } class SingletonHolder { private static Mod INSTANCE = null; public static void register(Mod mod) { requireNonNull(mod); if (INSTANCE != null) throw new IllegalStateException("Mod singleton initialized twice"); SingletonHolder.INSTANCE = mod; } } // ====================================================================== // Loader-facing methods /** * Initializes the mod for a dedicated server. Call once at startup. */ static void initializeForDedicatedServer(LoaderHelper loaderHelper) { ModImpl.initialize(loaderHelper, null); } /** * Initializes the mod for a client (integrated or dedicated-server-from-client). Call once at startup. */ static void initializeForClient(LoaderHelper loaderHelper, ClientHelper clientHelper) { ModImpl.initialize(loaderHelper, clientHelper); } /** * Must be called when a world is starting so that we can have a reference * to the active world. */ void onWorldStart(MinecraftServer server); /** * Must be called when a world is stopping to ensure we can run a * shutdown backup. */ void onWorldStop(); /** * Allows loaders to plugin HUD rendering. */ void renderHud(GuiGraphicsExtractor drawContext); // ====================================================================== // Mixin-facing methods /** * Called from the mixins to check whether vanilla autosaving should * be disabled. Autosaving while a backup is in progress could result * in an inconsistent backup state. */ boolean isWorldSaveEnabled(); /** * Called from the mixins to tell us that an autosave just finished. * This may trigger a backup, depending on configuration. */ void autoSaveCompleted(); /** * Called from the shutdown message screen mixins to render additional text. */ void renderMessageScreen(GuiGraphicsExtractor drawContext); // ====================================================================== // Command-facing methods /** * @return path to where snapshots should be restored. */ Path getDefaultRestoresDir() throws IOException; /** * @return the version of the fastback mod. */ String getModVersion(); /** * Enables or disables world saving. */ void setWorldSaveEnabled(boolean enabled); /** * Forces a save of the world. We often want to do this before doing a backup. */ void saveWorld(); /** * If we're clientside and the user is looking at a MessageScreen, set the title. */ void setMessageScreenText(UserMessage message); /** * Send a chat message to user. */ void sendChat(UserMessage message, CommandSourceStack scs); /** * If on a dedicated server, broadcast a message to the chat window of all connected users. */ void sendBroadcast(UserMessage message); /** * Set magical floating text. You MUST call clearHudText */ void setHudText(UserMessage message); /** * Remove the magical floating text. */ void clearHudText(); /** * @return path to the save directory of the currently-loaded world (aka the git worktree). */ Path getWorldDirectory(); /** * @return name of the currently-loaded world. */ String getWorldName(); /** * @return paths to backup when mods-backup enabled. */ Collection getModsBackupPaths(); /** * Add extra properties that will be stored in .fastback/backup.properties. */ void addBackupProperties(Map props); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/ModImpl.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.storage.LevelStorageSource; import net.pcal.fastback.common.commands.Commands; import net.pcal.fastback.common.commands.SchedulableAction; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.mixins.ServerAccessors; import net.pcal.fastback.common.mixins.SessionAccessors; import net.pcal.fastback.common.repo.Repo; import net.pcal.fastback.common.repo.RepoFactory; import org.apache.logging.log4j.LogManager; import org.eclipse.jgit.transport.SshSessionFactory; import java.io.IOException; import java.nio.file.Path; import java.util.Collection; import java.util.Map; import static java.nio.file.Files.createTempDirectory; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.SHUTDOWN_ACTION; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.mod.UserMessageUtil.messageToText; import static net.pcal.fastback.common.utils.EnvironmentUtils.getGitLfsVersion; import static net.pcal.fastback.common.utils.EnvironmentUtils.getGitVersion; import static net.pcal.fastback.common.utils.Executor.executor; class ModImpl implements Mod { // ====================================================================== // Fields private final LoaderHelper loaderHelper; private final ClientHelper clientHelper; // null on a dedicated server private final Runnable autoSaveListener; private MinecraftServer minecraftServer = null; // currently open world private boolean isWorldSaveEnabled = true; private Path tempRestoresDirectory = null; // ====================================================================== // Factory — called by loader initializers /** * Creates, registers, and initializes a ModImpl. * * @param loaderHelper loader-specific services (always present) * @param clientHelper client-specific services, or null on a dedicated server */ static Mod initialize(final LoaderHelper loaderHelper, final ClientHelper clientHelper) { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("fastback"))); final ModImpl mod = new ModImpl(loaderHelper, clientHelper); SingletonHolder.register(mod); mod.onInitialize(); return mod; } private ModImpl(LoaderHelper loaderHelper, ClientHelper clientHelper) { this.loaderHelper = requireNonNull(loaderHelper); this.clientHelper = clientHelper; // nullable — null means dedicated server this.autoSaveListener = new AutosaveListener(); } // ====================================================================== // Mod implementation @Override public void onWorldStart(final MinecraftServer minecraftServer) { this.minecraftServer = requireNonNull(minecraftServer); executor().start(); syslog().debug("onWorldStart complete"); } @Override public void onWorldStop() { try (final UserLogger ulog = UserLogger.forShutdown()) { final Path worldSaveDir = this.getWorldDirectory(); if (executor().getActiveCount() > 0) { this.setMessageScreenText(localized("fastback.chat.thread-waiting")); } executor().stop(); this.clearHudText(); final RepoFactory rf = RepoFactory.rf(); if (rf.isGitRepo(worldSaveDir)) { try (final Repo repo = rf.load(worldSaveDir)) { final GitConfig config = repo.getConfig(); if (config.getBoolean(IS_BACKUP_ENABLED)) { final SchedulableAction action = SchedulableAction.forConfigValue(config, SHUTDOWN_ACTION); if (action != null) { this.setMessageScreenText(localized("fastback.message.backing-up")); action.getTask(repo, ulog).call(); this.setMessageScreenText(localized("fastback.chat.backup-complete")); } } } catch (Exception e) { syslog().error("Shutdown action failed.", e); } } syslog().debug("onWorldStop complete"); } this.minecraftServer = null; } @Override public Path getDefaultRestoresDir() throws IOException { Path savesDir = this.loaderHelper.getSavesDir(); if (savesDir != null) return savesDir; if (tempRestoresDirectory == null) { tempRestoresDirectory = createTempDirectory("fastback-restore"); } return tempRestoresDirectory; } @Override public String getModVersion() { return this.loaderHelper.getModVersion(); } @Override public void setWorldSaveEnabled(boolean enabled) { this.isWorldSaveEnabled = enabled; } @Override public void saveWorld() { if (this.minecraftServer == null) throw new IllegalStateException(); this.minecraftServer.saveEverything(false, true, true); } @Override public void sendChat(UserMessage message, CommandSourceStack scs) { if (message.style() == ERROR) { scs.sendFailure(messageToText(message)); } else { scs.sendSuccess(() -> messageToText(message), false); } } @Override public void sendBroadcast(UserMessage userMessage) { if (this.minecraftServer != null && this.minecraftServer.isDedicatedServer()) { this.minecraftServer.getPlayerList().broadcastSystemMessage(messageToText(userMessage), false); } } @Override public void setHudText(UserMessage message) { if (this.clientHelper == null) return; if (message == null) { syslog().debug("null unexpectedly passed to setHudText, ignoring"); this.clearHudText(); } else { this.clientHelper.setHudText(message); } } @Override public void clearHudText() { if (this.clientHelper != null) this.clientHelper.clearHudText(); } @Override public void setMessageScreenText(UserMessage message) { if (this.clientHelper != null) this.clientHelper.setMessageScreenText(message); } @Override public Path getWorldDirectory() { if (this.minecraftServer == null) throw new IllegalStateException(); final LevelStorageSource.LevelStorageAccess session = ((ServerAccessors) this.minecraftServer).getStorageSource(); return ((SessionAccessors) session).getLevelDirectory().path(); } @Override public String getWorldName() { if (this.minecraftServer == null) throw new IllegalStateException(); return this.minecraftServer.getWorldData().getLevelName(); } @Override public void addBackupProperties(Map props) { props.put("fastback-version", this.getModVersion()); if (this.minecraftServer != null) { props.put("minecraft-version", minecraftServer.getServerVersion()); props.put("minecraft-game-mode", String.valueOf(minecraftServer.getWorldData().getGameType())); props.put("minecraft-level-name", minecraftServer.getWorldData().getLevelName()); } this.loaderHelper.addLoaderBackupProperties(props); } @Override public Collection getModsBackupPaths() { return this.loaderHelper.getModsBackupPaths(); } // ====================================================================== // Mod implementation (continued) @Override public boolean isWorldSaveEnabled() { return this.isWorldSaveEnabled; } @Override public void autoSaveCompleted() { if (this.autoSaveListener != null) { this.autoSaveListener.run(); } else { syslog().warn("Autosave just happened but, unexpectedly, no one is listening."); } } @Override public void renderMessageScreen(GuiGraphicsExtractor drawContext) { if (this.clientHelper != null) { this.clientHelper.renderMessageScreen(drawContext); } else { syslog().warn("renderMessageScreen called when clientHelper not set."); } } @Override public void renderHud(GuiGraphicsExtractor drawContext) { if (this.clientHelper != null) { this.clientHelper.renderHud(drawContext); } else { syslog().warn("renderHud called when clientHelper not set."); } } // ====================================================================== // Private methods private void onInitialize() { final String gitVersion = getGitVersion(); if (gitVersion == null) { syslog().warn("git is not installed."); } else { syslog().info("git is installed: " + gitVersion); } final String gitLfsVersion = getGitLfsVersion(); if (gitLfsVersion == null) { syslog().warn("git-lfs is not installed."); } else { syslog().info("git-lfs is installed: " + gitLfsVersion); } if (SshSessionFactory.getInstance() == null) { syslog().warn("An ssh provider was not initialized for jgit. Operations on a remote repo over ssh will fail."); } else { syslog().info("SshSessionFactory: " + SshSessionFactory.getInstance()); } this.loaderHelper.registerBackupCommand(clientHelper != null, Commands::createBackupCommand); syslog().debug("onInitialize complete"); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/mod/UserMessageUtil.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.mod; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.TextColor; import net.pcal.fastback.common.logging.UserMessage; import static net.minecraft.ChatFormatting.GRAY; import static net.minecraft.ChatFormatting.GREEN; import static net.minecraft.ChatFormatting.RED; import static net.minecraft.ChatFormatting.YELLOW; import static net.minecraft.network.chat.Style.EMPTY; /** * Utility for converting {@link UserMessage} to Minecraft {@link Component}. * * @author pcal * @since 0.2.0 */ public class UserMessageUtil { public static Component messageToText(final UserMessage m) { final MutableComponent out; if (m.localized() != null) { out = Component.translatable( m.localized().key(), messageParamsToComponentArgs(m.localized().params()) ); } else { out = Component.literal(m.raw()); } switch (m.style()) { case ERROR -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(RED))); case WARNING -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(YELLOW))); case JGIT -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(GRAY))); case NATIVE_GIT -> out.setStyle(EMPTY.withColor(TextColor.fromLegacyFormat(GREEN))); } return out; } private static Object[] messageParamsToComponentArgs(final Object[] params) { if (params == null) return new Object[0]; final Object[] out = new Object[params.length]; for (int i = 0; i < params.length; i++) { final Object param = params[i]; if (param instanceof Component) { out[i] = param; } else { out[i] = String.valueOf(param); } } return out; } private UserMessageUtil() {} } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/BranchUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec; import org.eclipse.jgit.api.errors.GitAPIException; import java.io.IOException; import java.text.ParseException; import java.util.Collection; import java.util.HashSet; import java.util.Set; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * @author pcal */ abstract class BranchUtils { /** * Get the snapshots for this repo. Snapshot branches for worlds other than the Repo's are ignored. */ static Set listSnapshots(RepoImpl repo, JGitSupplier> refProvider) throws GitAPIException, IOException { final Collection refs = refProvider.get(); final SnapshotIdCodec codec = repo.getSidCodec(); final Set out = new HashSet<>(); for (final String ref : refs) { String branchName = getBranchName(ref); if (repo.getSidCodec().isSnapshotBranchName(repo.getWorldId(), branchName)) { final SnapshotId sid; try { sid = requireNonNull(codec.fromBranch(branchName)); } catch (ParseException pe) { syslog().error("Unexpected parse error, ignoring branch " + branchName, pe); continue; } if (sid.getWorldId().equals(repo.getWorldId())) { out.add(sid); } else { syslog().debug("Ignoring branch from other world " + branchName); } } else { syslog().debug("Ignoring unrecognized branch " + branchName); } } return out; } static String getBranchName(String name) { final String REFS_HEADS = "refs/heads/"; if (name.startsWith(REFS_HEADS)) { return name.substring(REFS_HEADS.length()); } else { return null; } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/CommitUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.utils.EnvironmentUtils; import net.pcal.fastback.common.utils.ProcessException; import org.apache.commons.io.FileUtils; import org.eclipse.jgit.api.AddCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.RmCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.GitAPIException; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_MODS_BACKUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.RepoImpl.FASTBACK_DIR; import static net.pcal.fastback.common.utils.ProcessUtils.doExec; /** * Utilities for adding and committing snapshots. * * @author pcal * @since 0.13.0 */ abstract class CommitUtils { static SnapshotId doCommitSnapshot(final RepoImpl repo, final UserLogger ulog) throws IOException, ProcessException, GitAPIException { PreflightUtils.doPreflight(repo); final WorldId uuid = repo.getWorldId(); final GitConfig conf = repo.getConfig(); final SnapshotId newSid = repo.getSidCodec().create(uuid); syslog().debug("start doCommitSnapshot for " + newSid); writeBackupProperties(repo); if (conf.getBoolean(IS_MODS_BACKUP_ENABLED)) { doSettingsBackup(repo, ulog); } final String newBranchName = newSid.getBranchName(); try { if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) { ulog.message(styledLocalized("fastback.chat.commit-start", NATIVE_GIT, newSid.getShortName())); native_commit(newBranchName, repo, ulog); } else { ulog.message(styledLocalized("fastback.chat.commit-start", NORMAL, newSid.getShortName())); jgit_commit(newBranchName, repo.getJGit(), ulog); } } catch (GitAPIException | InterruptedException e) { throw new IOException(e); } syslog().debug("Local backup complete."); return newSid; } private static void doSettingsBackup(RepoImpl repo, UserLogger ulog) { syslog().info("Backing up minecraft settings"); try { final File backupDir = repo.getDotFasbackDir().resolve("mods-backup").toFile(); if (backupDir.exists()) FileUtils.deleteDirectory(backupDir); backupDir.mkdirs(); for (Path src : mod().getModsBackupPaths()) { try { final File srcFile = src.toFile(); syslog().debug("backing up " + srcFile + " to " + backupDir); if (srcFile.exists()) { if (srcFile.isDirectory()) { FileUtils.copyDirectory(srcFile, backupDir.toPath().resolve(srcFile.getName()).toFile()); } else { FileUtils.copyFile(srcFile, backupDir); } } } catch (Exception ohwell) { syslog().error(ohwell); } } } catch (Exception ohwell) { syslog().error(ohwell); } } private static void native_commit(final String newBranchName, final Repo repo, final UserLogger ulog) throws IOException, InterruptedException { syslog().debug("Start native_commit"); ulog.update(styledLocalized("fastback.hud.local-saving", NATIVE_GIT)); final File worktree = repo.getWorkTree(); final Map env = Map.of("GIT_LFS_FORCE_PROGRESS", "1"); final Consumer outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT)); String[] checkout = {"git", "-C", worktree.getAbsolutePath(), "checkout", "--orphan", newBranchName}; try { doExec(checkout, env, outputConsumer, outputConsumer); mod().setWorldSaveEnabled(false); try { String[] add = {"git", "-C", worktree.getAbsolutePath(), "add", "-v", "."}; doExec(add, env, outputConsumer, outputConsumer); } finally { mod().setWorldSaveEnabled(true); syslog().debug("World save re-enabled."); } { String[] commit = {"git", "-C", worktree.getAbsolutePath(), "commit", "-m", newBranchName}; doExec(commit, env, outputConsumer, outputConsumer); } } catch (ProcessException e) { syslog().error(e); ulog.message(styledLocalized("fastback.chat.commit-failed", ERROR)); return; } syslog().debug("End native_commit"); } private static void jgit_commit(final String newBranchName, final Git jgit, final UserLogger ulog) throws GitAPIException, IOException { syslog().debug("Starting jgit_commit"); ulog.update(styledLocalized("fastback.hud.local-saving", JGIT)); jgit.checkout().setOrphan(true).setName(newBranchName).call(); jgit.reset().setMode(ResetCommand.ResetType.SOFT).call(); syslog().debug("status"); final Status status = jgit.status().call(); try { syslog().debug("Disabling world save for 'git add'"); mod().setWorldSaveEnabled(false); // // Figure out what files to add and remove. We don't just 'git add .' because this: // https://bugs.eclipse.org/bugs/show_bug.cgi?id=494323 // { final List toAdd = new ArrayList<>(); toAdd.add(FASTBACK_DIR); toAdd.addAll(status.getModified()); toAdd.addAll(status.getUntracked()); Collections.sort(toAdd); if (!toAdd.isEmpty()) { syslog().debug("Adding " + toAdd.size() + " new or modified files to index"); for (final String file : toAdd) { final AddCommand gitAdd = jgit.add(); syslog().debug("add " + file); ulog.update(styledLocalized("fastback.chat.backup-start", JGIT, file)); gitAdd.addFilepattern(file); gitAdd.call(); } } } { final List toDelete = new ArrayList<>(); toDelete.addAll(status.getRemoved()); toDelete.addAll(status.getMissing()); Collections.sort(toDelete); if (!toDelete.isEmpty()) { syslog().debug("Removing " + toDelete.size() + " deleted files from index"); for (final String file : toDelete) { final RmCommand gitRm = jgit.rm(); syslog().debug("rm " + file); ulog.update(styledLocalized("fastback.chat.backup-start", JGIT, file)); gitRm.addFilepattern(file); gitRm.call(); } } } } finally { mod().setWorldSaveEnabled(true); syslog().debug("World save re-enabled."); } syslog().debug("commit"); ulog.update(styledLocalized("fastback.chat.commit-complete", JGIT)); jgit.commit().setMessage(newBranchName).call(); } private static void writeBackupProperties(Repo repo) throws IOException { final Map props = new HashMap<>(); GitConfig conf = repo.getConfig(); props.put("fastback-" + IS_NATIVE_GIT_ENABLED.getSettingName(), conf.getString(IS_NATIVE_GIT_ENABLED)); props.put("git-version", EnvironmentUtils.getGitVersion()); props.put("git-lfs-version", EnvironmentUtils.getGitLfsVersion()); try { mod().addBackupProperties(props); } catch (Exception e) { syslog().error("Failed to add extra backup.properties", e); } final Path path = repo.getWorkTree().toPath().resolve(FASTBACK_DIR + "/backup.properties"); final List keys = new ArrayList<>(props.keySet()); try (final PrintWriter pw = new PrintWriter(new FileWriter(path.toFile()))) { Collections.sort(keys); for (String key : keys) { pw.println(key + " = " + props.get(key)); } } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/JGitConsumer.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import java.io.IOException; /** * @author pcal */ @FunctionalInterface interface JGitConsumer { void accept(T t) throws IOException; } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/JGitFunction.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import org.eclipse.jgit.api.errors.GitAPIException; import java.io.IOException; /** * Function with typed exceptions for typical JGit operations. * * @author pcal */ @FunctionalInterface interface JGitFunction { R apply(T arg) throws IOException, GitAPIException; } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/JGitIncrementalProgressMonitor.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import org.eclipse.jgit.lib.ProgressMonitor; import static java.util.Objects.requireNonNull; class JGitIncrementalProgressMonitor implements ProgressMonitor { private final ProgressMonitor delegate; private final int totalIncrements; private int workComplete; private int totalWork; private int workCompletedInIncrement; private int workCompleteScaled; public JGitIncrementalProgressMonitor(ProgressMonitor delegate, int totalIncrements) { this.delegate = requireNonNull(delegate); this.totalIncrements = totalIncrements; } @Override public void start(int totalTasks) { this.delegate.start(totalTasks); } @Override public void beginTask(String taskName, int totalWork) { this.delegate.beginTask(taskName, totalWork); this.totalWork = totalWork; this.workComplete = 0; this.workCompleteScaled = 0; this.workCompletedInIncrement = 0; } @Override public void update(int completed) { this.workComplete += completed; this.workCompletedInIncrement += completed; if (this.totalWork == 0) return; int newWorkCompleteScaled = (this.workComplete * totalIncrements) / this.totalWork; if (newWorkCompleteScaled > this.workCompleteScaled) { this.workCompleteScaled = newWorkCompleteScaled; this.delegate.update(workCompletedInIncrement); this.workCompletedInIncrement = 0; } } @Override public void endTask() { this.delegate.endTask(); } @Override public boolean isCancelled() { return false; } @Override public void showDuration(boolean enabled) { } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/JGitPercentageProgressMonitor.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import org.eclipse.jgit.lib.ProgressMonitor; abstract class JGitPercentageProgressMonitor implements ProgressMonitor { private String currentTask; private int currentTotalWork; private int totalCompleted; protected JGitPercentageProgressMonitor() { } @Override final public void start(int totalTasks) { } @Override final public void beginTask(String taskName, int totalWork) { this.currentTask = taskName; this.currentTotalWork = totalWork; this.totalCompleted = 0; this.progressStart(currentTask); } @Override final public void update(int completed) { this.totalCompleted += completed; int percent = this.currentTotalWork == 0 ? 0 : (this.totalCompleted * 100) / this.currentTotalWork; this.progressUpdate(currentTask, percent); } @Override final public void endTask() { this.progressDone(currentTask); currentTask = null; } @Override final public boolean isCancelled() { return false; } protected abstract void progressStart(String taskName); protected abstract void progressUpdate(String taskName, int percentage); protected abstract void progressDone(String taskName); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/JGitSupplier.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import java.io.IOException; /** * Supplier with typed exceptions for typical JGit operations. */ @FunctionalInterface interface JGitSupplier { R get() throws IOException; } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/PreflightUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.utils.ProcessException; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.StoredConfig; import java.io.IOException; import java.nio.file.Path; import java.util.Collections; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.UPDATE_GITATTRIBUTES_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.UPDATE_GITIGNORE_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.utils.FileUtils.writeResourceToFile; import static net.pcal.fastback.common.utils.ProcessUtils.doExec; /** * Utilities for keeping the repo configuration up-to-date. * * @author pcal * @since 0.13.0 */ abstract class PreflightUtils { // ====================================================================== // Util methods /** * Should be called prior to any heavy-lifting with git (e.g. committing or pushing). Ensures that * key settings are all set correctly. */ static void doPreflight(RepoImpl repo) throws IOException, ProcessException, GitAPIException { final SystemLogger syslog = syslog(); syslog.debug("Doing world maintenance"); final Git jgit = repo.getJGit(); final Path worldSaveDir = jgit.getRepository().getWorkTree().toPath(); WorldIdUtils.ensureWorldHasId(worldSaveDir); final GitConfig config = GitConfig.load(jgit); if (config.getBoolean(UPDATE_GITIGNORE_ENABLED)) { final Path targetPath = worldSaveDir.resolve(".gitignore"); writeResourceToFile("world/gitignore", targetPath); } if (config.getBoolean(UPDATE_GITATTRIBUTES_ENABLED)) { final Path targetPath = worldSaveDir.resolve(".gitattributes"); if (config.getBoolean(IS_NATIVE_GIT_ENABLED)) { writeResourceToFile("world/gitattributes-native", targetPath); } else { writeResourceToFile("world/gitattributes-jgit", targetPath); } } updateNativeLfsInstallation(repo); } // ====================================================================== // Private /** * Ensures that git-lfs is installed or uninstalled in the worktree as appropriate. */ private static void updateNativeLfsInstallation(final RepoImpl repo) throws ProcessException, GitAPIException { if (repo.getConfig().getBoolean(IS_NATIVE_GIT_ENABLED)) { final String[] cmd = {"git", "-C", repo.getWorkTree().getAbsolutePath(), "lfs", "install", "--local"}; doExec(cmd, Collections.emptyMap(), s -> {}, s -> {}); } else { try { // jgit has builtin support for lfs, but it's weird not compatible with native lfs, so lets just // try to avoid letting them use it. StoredConfig jgitConfig = repo.getJGit().getRepository().getConfig(); jgitConfig.unsetSection("lfs", null); jgitConfig.unsetSection("filter", "lfs"); jgitConfig.save(); } catch (Exception ohwell) { syslog().debug(ohwell); } } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/PruneUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.FastbackConfigKey; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.retention.RetentionPolicy; import net.pcal.fastback.common.retention.RetentionPolicyCodec; import net.pcal.fastback.common.retention.RetentionPolicyType; import net.pcal.fastback.common.utils.ProcessException; import net.pcal.fastback.common.utils.ProcessUtils; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.transport.RefSpec; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Set; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.LOCAL_RETENTION_POLICY; import static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static org.apache.commons.lang3.function.Consumers.nop; /** * Utils for pruning and deleting snapshot branches. * * @author pcal * @since 0.13.0 */ abstract class PruneUtils { static void deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws IOException { GitConfig config = repo.getConfig(); try { if (config.getBoolean(IS_NATIVE_GIT_ENABLED)) { native_deleteRemoteBranch(repo, remoteBranchName); } else { jgit_deleteRemoteBranch(repo, remoteBranchName); } } catch (GitAPIException | ProcessException e) { throw new IOException(e); } } static void native_deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws ProcessException { String[] command = {"git", "-C", repo.getWorkTree().getAbsolutePath(), "push", repo.getConfig().getString(REMOTE_NAME), "--delete", remoteBranchName}; ProcessUtils.doExec(command, Collections.emptyMap(), nop(), nop(), true); } static void jgit_deleteRemoteBranch(final RepoImpl repo, String remoteBranchName) throws GitAPIException { RefSpec refSpec = new RefSpec() .setSource(null) .setDestination("refs/heads/" + remoteBranchName); repo.getJGit().push().setRefSpecs(refSpec).setRemote(remoteBranchName).call(); } static void deleteLocalBranches(final RepoImpl repo, List branchNames) throws IOException { try { repo.getJGit().branchDelete().setForce(true).setBranchNames(branchNames.toArray(new String[0])).call(); } catch (GitAPIException e) { throw new IOException(e); } } static Collection doLocalPrune(final RepoImpl repo, final UserLogger log) throws IOException { return doPrune(repo, log, LOCAL_RETENTION_POLICY, repo::getLocalSnapshots, sid -> { syslog().info("Pruning local snapshot " + sid.getBranchName()); deleteLocalBranches(repo, List.of(sid.getBranchName())); }, "fastback.chat.retention-policy-not-set" ); } static Collection doRemotePrune(RepoImpl repo, UserLogger ulog) throws IOException { return doPrune(repo, ulog, FastbackConfigKey.REMOTE_RETENTION_POLICY, repo::getRemoteSnapshots, sid -> { syslog().info("Pruning remote snapshot " + sid.getBranchName()); repo.deleteRemoteBranch(sid.getBranchName()); }, "fastback.chat.remote-retention-policy-not-set" ); } private static Collection doPrune(Repo repo, UserLogger log, FastbackConfigKey policyConfigKey, JGitSupplier> listSnapshotsFn, JGitConsumer deleteSnapshotsFn, String notSetKey) throws IOException { final GitConfig conf = repo.getConfig(); RetentionPolicy policy = null; final String policyConfig = conf.getString(policyConfigKey); if (policyConfig != null) { policy = RetentionPolicyCodec.INSTANCE.decodePolicy(RetentionPolicyType.getAvailable(), policyConfig); } if (policy == null) { log.message(styledLocalized(notSetKey, ERROR)); return null; } final Collection toPruneUnsorted = policy.getSnapshotsToPrune(listSnapshotsFn.get()); final List toPrune = new ArrayList<>(toPruneUnsorted); Collections.sort(toPrune); log.update(UserMessage.localized("fastback.hud.prune-started")); for (final SnapshotId sid : toPrune) { deleteSnapshotsFn.accept(sid); } return toPrune; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/PushUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import com.google.common.collect.ListMultimap; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.utils.ProcessException; import net.pcal.fastback.common.utils.ProcessUtils; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ProgressMonitor; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.transport.PushResult; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.RemoteConfig; import org.eclipse.jgit.transport.TrackingRefUpdate; import org.eclipse.jgit.transport.URIish; import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; import static java.util.Arrays.asList; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_SMART_PUSH_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_TEMP_BRANCH_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_TRACKING_BRANCH_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_UUID_CHECK_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NORMAL; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.utils.ProcessUtils.doExec; /** * Utils for pushing changes to a remote. * * @author pcal * @since 0.13.0 */ abstract class PushUtils { static boolean isTempBranch(String branchName) { return branchName.startsWith("temp/"); } // TODO stop throwing IOE // TODO stop passing repo static void doPush(SnapshotId sid, RepoImpl repo, UserLogger ulog) throws IOException, ProcessException { try { final GitConfig conf = repo.getConfig(); final String pushUrl = conf.getString(REMOTE_PUSH_URL); if (pushUrl == null) { syslog().warn("Skipping remote backup because no remote url has been configured."); return; } final Git jgit = repo.getJGit(); final Collection remoteBranchRefs; final String remoteName = conf.getString(REMOTE_NAME); if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) { remoteBranchRefs = native_lsRemote(repo.getWorkTree().toPath(), remoteName); } else { remoteBranchRefs = jgit_lsRemote(repo.getJGit(), remoteName); } final ListMultimap snapshotsPerWorld = SnapshotIdUtils.getSnapshotsPerWorld(remoteBranchRefs, repo.getSidCodec()); if (conf.getBoolean(IS_UUID_CHECK_ENABLED)) { boolean uuidCheckResult; try { uuidCheckResult = doWorldIdCheck(repo, snapshotsPerWorld.keySet()); } catch (final IOException e) { syslog().error("Unexpected exception thrown during id check", e); uuidCheckResult = false; } if (!uuidCheckResult) { final URIish remoteUri = getRemoteUri(repo.getJGit(), repo.getConfig().getString(REMOTE_NAME)); ulog.message(styledLocalized("fastback.chat.push-id-mismatch", ERROR, remoteUri)); syslog().error("Failing remote backup due to failed id check"); throw new IOException(); } } syslog().debug("Pushing to " + pushUrl); PreflightUtils.doPreflight(repo); if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) { ulog.message(styledLocalized("fastback.chat.push-started", NATIVE_GIT, pushUrl)); native_doPush(repo, sid.getBranchName(), ulog); } else if (conf.getBoolean(IS_SMART_PUSH_ENABLED)) { ulog.message(styledLocalized("fastback.chat.push-started", NORMAL, pushUrl)); final WorldId uuid = repo.getWorldId(); jgit_doSmartPush(repo, snapshotsPerWorld.get(uuid), sid.getBranchName(), conf, ulog); } else { ulog.message(styledLocalized("fastback.chat.push-started", NORMAL, pushUrl)); jgit_doPush(jgit, sid.getBranchName(), conf, ulog); } syslog().info("Remote backup complete."); } catch (GitAPIException e) { throw new IOException(e); } } // TODO stop passing repo private static void native_doPush(final Repo repo, final String branchNameToPush, final UserLogger log) throws ProcessException { syslog().debug("Start native_push"); final File worktree = repo.getWorkTree(); final GitConfig conf = repo.getConfig(); String remoteName = conf.getString(REMOTE_NAME); final String[] push = {"git", "-C", worktree.getAbsolutePath(), "-c", "push.autosetupremote=false", "push", "--progress", "--set-upstream", remoteName, branchNameToPush}; final Map env = Map.of("GIT_LFS_FORCE_PROGRESS", "1"); final Consumer outputConsumer = line -> log.update(styledRaw(line, NATIVE_GIT)); doExec(push, env, outputConsumer, outputConsumer); syslog().debug("End native_push"); } private static void jgit_doPush(final Git jgit, final String branchNameToPush, final GitConfig conf, final UserLogger ulog) throws GitAPIException { final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitPushProgressMonitor(ulog), 100); final String remoteName = conf.getString(REMOTE_NAME); syslog().info("Doing simple push of " + branchNameToPush); jgit.push().setProgressMonitor(pm).setRemote(remoteName). setRefSpecs(new RefSpec(branchNameToPush + ":" + branchNameToPush)).call(); } static Collection native_lsRemote(final Path worktree, final String remoteName) throws ProcessException { final List command = new ArrayList<>(asList("git", "-C", worktree.toAbsolutePath().toString(), "ls-remote", "--heads", remoteName)); final List result = new ArrayList<>(); ProcessUtils.doExec( command.toArray(String[]::new), Map.of(), line -> { if (!line.isEmpty()) { String refName = line.split("\t")[1]; result.add(refName); } }, unused -> {}, false ); return result; } static Collection jgit_lsRemote(final Git jgit, final String remoteName) throws GitAPIException { return jgit.lsRemote().setHeads(true).setTags(false).setRemote(remoteName).call() .stream().map(Ref::getName).toList(); } /** * This is a probably-failed attempt at an optimization. It is no longer the default behavior. *

* The idea was to try to minimize re-pushing unchanged blobs by establishing a common merge history between the * branch being pushed and one we already know is upstream Again, all snapshot branches are orphan branches, and * git can't bother checking every single blob in unrelated branches when receiving a push. *

* But it does do some of this when there is a related history, so the idea here is to create a temporary merge * commit between a branch the server has and the new one it doesn't have, and to then let it figure out that most * of the blobs in those commits are already present on the server. Unfortunately, I think I may misunderstand * how git behaves here - I think the deduplication is only at commit granularity, so this actually isn't doing * anything except adding a lot of moving parts and problems like this one: *

* https://github.com/pcal43/fastback/issues/267 *

* So, this is no longer enabled by default. And really, if they're backing up a big world where this matters, * you should just be using native git. */ private static void jgit_doSmartPush(final RepoImpl repo, List remoteSnapshots, final String branchNameToPush, final GitConfig conf, final UserLogger ulog) throws IOException { ulog.update(styledLocalized("fastback.chat.push-started", JGIT)); try { final Git jgit = repo.getJGit(); final String remoteName = conf.getString(REMOTE_NAME); final WorldId worldUuid = repo.getWorldId(); final SnapshotId latestCommonSnapshot; if (remoteSnapshots.isEmpty()) { syslog().warn("** This appears to be the first time this world has been pushed."); syslog().warn("** If the world is large, this may take some time."); jgit_doPush(jgit, branchNameToPush, conf, ulog); return; } else { final Collection localBranchRefs = jgit.branchList().call(); final ListMultimap localSnapshotsPerWorld = SnapshotIdUtils.getSnapshotsPerWorld(localBranchRefs.stream().map(Ref::getName).toList(), repo.getSidCodec()); final List localSnapshots = localSnapshotsPerWorld.get(worldUuid); remoteSnapshots.retainAll(localSnapshots); if (remoteSnapshots.isEmpty()) { syslog().warn("No common snapshots found between local and remote."); syslog().warn("Doing a full push. This may take some time."); jgit_doPush(jgit, branchNameToPush, conf, ulog); return; } else { Collections.sort(remoteSnapshots); latestCommonSnapshot = remoteSnapshots.get(remoteSnapshots.size() - 1); syslog().debug("Using existing snapshot " + latestCommonSnapshot + " for common history"); } } // ok, we have a common snapshot that we can use to create a fake merge history. final String tempBranchName = "temp/" + branchNameToPush; syslog().debug("Creating out temp branch " + tempBranchName); jgit.checkout().setCreateBranch(true).setName(tempBranchName).call(); final ObjectId branchId = jgit.getRepository().resolve(latestCommonSnapshot.getBranchName()); syslog().debug("Merging " + latestCommonSnapshot.getBranchName()); jgit.merge().setContentMergeStrategy(ContentMergeStrategy.OURS). include(branchId).setMessage("Merge " + branchId + " into " + tempBranchName).call(); syslog().debug("Checking out " + branchNameToPush); jgit.checkout().setName(branchNameToPush).call(); syslog().debug("Pushing temp branch " + tempBranchName); final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitPushProgressMonitor(ulog), 100); final Iterable pushResult = jgit.push().setProgressMonitor(pm).setRemote(remoteName). setRefSpecs(new RefSpec(tempBranchName + ":" + tempBranchName), new RefSpec(branchNameToPush + ":" + branchNameToPush)).call(); syslog().debug("Cleaning up branches..."); if (conf.getBoolean(IS_TRACKING_BRANCH_CLEANUP_ENABLED)) { for (final PushResult pr : pushResult) { for (final TrackingRefUpdate f : pr.getTrackingRefUpdates()) { final String PREFIX = "refs/remotes/"; if (f.getLocalName().startsWith(PREFIX)) { final String trackingBranchName = f.getLocalName().substring(PREFIX.length()); syslog().debug("Cleaning up tracking branch " + trackingBranchName); jgit.branchDelete().setForce(true).setBranchNames(trackingBranchName).call(); } else { syslog().warn("Ignoring unrecognized TrackingRefUpdate " + f.getLocalName()); } } } } if (conf.getBoolean(IS_TEMP_BRANCH_CLEANUP_ENABLED)) { syslog().debug("Deleting local temp branch " + tempBranchName); jgit.branchDelete().setForce(true).setBranchNames(tempBranchName).call(); } if (conf.getBoolean(IS_REMOTE_TEMP_BRANCH_CLEANUP_ENABLED)) { final String remoteTempBranch = "refs/heads/" + tempBranchName; syslog().debug("Deleting remote temp branch " + remoteTempBranch); final RefSpec deleteRemoteBranchSpec = new RefSpec().setSource(null).setDestination(remoteTempBranch); jgit.push().setProgressMonitor(pm).setRemote(remoteName).setRefSpecs(deleteRemoteBranchSpec).call(); } syslog().info("Push complete"); } catch (GitAPIException e) { throw new IOException(e); } } // TODO stop passing repo private static boolean doWorldIdCheck(RepoImpl repo, Set remoteWorldUuids) throws IOException { final WorldId localUuid = repo.getWorldId(); if (remoteWorldUuids.size() > 2) { syslog().warn("Remote has more than one world-id. This is unusual. " + remoteWorldUuids); } if (remoteWorldUuids.isEmpty()) { syslog().debug("Remote does not have any previously-backed up worlds."); } else { if (!remoteWorldUuids.contains(localUuid)) { syslog().debug("local: " + localUuid + ", remote: " + remoteWorldUuids); return false; } } syslog().debug("world-id check passed."); return true; } private static URIish getRemoteUri(Git jgit, String remoteName) throws IOException { requireNonNull(jgit); requireNonNull(remoteName); final List remotes; try { remotes = jgit.remoteList().call(); } catch (GitAPIException e) { throw new IOException(e); } for (final RemoteConfig remote : remotes) { syslog().debug("getRemoteUri " + remote); if (remote.getName().equals(remoteName)) { return remote.getPushURIs() != null && !remote.getURIs().isEmpty() ? remote.getURIs().get(0) : null; } } return null; } private static class JGitPushProgressMonitor extends JGitPercentageProgressMonitor { private final UserLogger ulog; public JGitPushProgressMonitor(UserLogger ulog) { this.ulog = requireNonNull(ulog); } @Override public void progressStart(String task) { syslog().debug(task); } @Override public void progressUpdate(String task, int percentage) { final String msg = task + " " + percentage + "%"; syslog().debug(msg); ulog.update(styledRaw(msg, JGIT)); } @Override public void progressDone(String task) { final UserMessage msg = styledLocalized("fastback.chat.push-done", JGIT); syslog().debug(msg.toString()); ulog.update(msg); } @Override public void showDuration(boolean enabled) { } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/ReclamationUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.utils.FileUtils; import net.pcal.fastback.common.utils.ProcessException; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.internal.storage.file.FileRepository; import org.eclipse.jgit.internal.storage.file.GC; import org.eclipse.jgit.lib.ProgressMonitor; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.storage.pack.PackConfig; import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.text.ParseException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.Consumer; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_BRANCH_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_REFLOG_DELETION_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.JGIT; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT; import static net.pcal.fastback.common.logging.UserMessage.raw; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.repo.PushUtils.isTempBranch; import static net.pcal.fastback.common.utils.ProcessUtils.doExec; import static org.apache.commons.io.FileUtils.byteCountToDisplaySize; import static org.apache.commons.io.FileUtils.sizeOfDirectory; import static org.eclipse.jgit.api.ListBranchCommand.ListMode.ALL; /** * Utilities for reclaiming disk space from pruned branches. * * @author pcal * @since 0.13.0 */ abstract class ReclamationUtils { static void doReclamation(RepoImpl repo, UserLogger ulog) throws GitAPIException, ProcessException { if (repo.getConfig().getBoolean(IS_NATIVE_GIT_ENABLED)) { native_doLfsPrune(repo, ulog); } else { try { jgit_doGc(repo, ulog); } catch (ParseException | IOException e) { throw new RuntimeException(e); } } } private static void native_doLfsPrune(RepoImpl repo, UserLogger ulog) throws ProcessException { final File worktree = repo.getWorkTree(); final String[] push = {"git", "-C", worktree.getAbsolutePath(), "-c", "lfs.pruneoffsetdays=999999", "lfs", "prune", "--verbose", "--no-verify-remote",}; final Consumer outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT)); doExec(push, Collections.emptyMap(), outputConsumer, outputConsumer); syslog().debug("native_doLfsPrune"); } /** * Runs git garbage collection. Aggressively deletes reflogs, tracking branches and stray temporary branches * in an attempt to free up objects and reclaim disk space. */ private static void jgit_doGc(RepoImpl repo, UserLogger ulog) throws GitAPIException, ParseException, IOException { final File gitDir = repo.getJGit().getRepository().getDirectory(); final GitConfig config = repo.getConfig(); ulog.update(styledLocalized("fastback.hud.gc-percent", JGIT, 0)); syslog().debug("Stats before gc:"); syslog().debug(String.valueOf(repo.getJGit().gc().getStatistics())); final long sizeBeforeBytes = sizeOfDirectory(gitDir); syslog().info("Backup size before gc: " + byteCountToDisplaySize(sizeBeforeBytes)); if (config.getBoolean(IS_REFLOG_DELETION_ENABLED)) { // reflogs aren't very useful in our case and cause old snapshots to get retained // longer than people expect. final Path reflogsDir = gitDir.toPath().resolve("logs"); syslog().debug("Deleting reflogs " + reflogsDir); FileUtils.rmdir(reflogsDir); } if (config.getBoolean(IS_BRANCH_CLEANUP_ENABLED)) { final List branchesToDelete = new ArrayList<>(); for (final Ref ref : repo.getJGit().branchList().setListMode(ALL).call()) { final String branchName = BranchUtils.getBranchName(ref.getName()); if (branchName == null) { syslog().warn("Non-branch ref returned by branchList: " + ref); } else if (isTempBranch(branchName)) { branchesToDelete.add(branchName); } else if (repo.getSidCodec().isSnapshotBranchName(repo.getWorldId(), branchName)) { // ok fine } else { syslog().warn("Unidentified branch found " + branchName + " - consider removing it with 'git branch -D'"); } } if (branchesToDelete.isEmpty()) { syslog().debug("No branches to clean up"); } else { syslog().debug("Deleting branches: " + branchesToDelete); repo.deleteLocalBranches(branchesToDelete); syslog().debug("Branches deleted."); } } final GC gc = new GC(((FileRepository) repo.getJGit().getRepository())); gc.setExpireAgeMillis(0); gc.setPackExpireAgeMillis(0); gc.setAuto(false); final PackConfig pc = new PackConfig(); pc.setDeltaCompress(false); gc.setPackConfig(pc); final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new GcProgressMonitor(ulog), 100); gc.setProgressMonitor(pm); syslog().debug("Starting garbage collection"); gc.gc(); // TODO progress monitor syslog().debug("Garbage collection complete."); syslog().debug("Stats after gc:"); syslog().debug("" + repo.getJGit().gc().getStatistics()); final long sizeAfterBytes = sizeOfDirectory(gitDir); syslog().info("Backup size after gc: " + byteCountToDisplaySize(sizeAfterBytes)); } private static class GcProgressMonitor extends JGitPercentageProgressMonitor { private final UserLogger ulog; public GcProgressMonitor(UserLogger ulog) { this.ulog = requireNonNull(ulog); } @Override public void progressStart(String task) { this.ulog.update(raw(task)); } @Override public void progressUpdate(String task, int percentage) { final String message = task + " " + percentage + "%"; syslog().debug(message); this.ulog.update(styledLocalized(message, JGIT)); } @Override public void progressDone(String task) { final UserMessage msg = styledLocalized("fastback.chat.gc-done-no-reclaim", JGIT); syslog().debug(msg.toString()); ulog.update(msg); } @Override public void showDuration(boolean enabled) { } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/Repo.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.errors.NoWorkTreeException; import java.io.File; import java.io.IOException; import java.text.ParseException; import java.util.Collection; import java.util.List; import java.util.Set; /** * Encapsulates everything the mod needs to do to the git repo. * * @author pcal * @since 0.13.0 */ public interface Repo extends AutoCloseable { Set getLocalSnapshots() throws IOException; Set getRemoteSnapshots() throws IOException; // ====================================================================== // 'do' methods. // // By convention, methods prefixed with 'do' provide the 'guts' of a flow // initiated by a cli command or scheduled action. They're expected to handle // everything: errors, user feedback. A method prefixed with 'do' must return // void and must not throw checked exceptions. // // q: should they also be responsible for thread management? probably yes // Obviously there are still some TODOs here to align with this convention. // void doCommitAndPush(UserLogger ulog) throws IOException; void doCommitSnapshot(UserLogger ulog) throws IOException; Collection doLocalPrune(UserLogger ulog) throws IOException; Collection doRemotePrune(UserLogger ulog) throws IOException; void doRestoreLocalSnapshot(String snapshotName, UserLogger ulog); void doRestoreRemoteSnapshot(String snapshotName, UserLogger ulog); void doGc(UserLogger ulog); void doPushSnapshot(SnapshotId sid, UserLogger ulog); void deleteRemoteBranch(String remoteBranchName) throws IOException; void deleteLocalBranches(List branchesToDelete) throws GitAPIException, IOException; // ====================================================================== // Any callers of these methods are doing too much; they need to be given a // 'do' method instead @Deprecated SnapshotId createSnapshotId(String date) throws IOException, ParseException; @Deprecated GitConfig getConfig(); @Deprecated WorldId getWorldId() throws IOException; @Deprecated File getDirectory() throws NoWorkTreeException; @Deprecated File getWorkTree() throws NoWorkTreeException; } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/RepoFactory.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.logging.UserLogger; import java.io.IOException; import java.nio.file.Path; /** * Creates Repo instances. * * @author pcal * @since 0.13.0 */ public interface RepoFactory { // TODO this probably should move to ModContext static RepoFactory rf() { return new RepoFactoryImpl(); } void doInit(Path worldSaveDir, UserLogger ulog) throws IOException; Repo load(final Path worldSaveDir) throws IOException; boolean doInitCheck(Path worldSaveDir, UserLogger ulog); boolean isGitRepo(Path worldSaveDir); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/RepoFactoryImpl.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig.Updater; import net.pcal.fastback.common.logging.UserLogger; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.StoredConfig; import java.io.File; import java.io.IOException; import java.nio.file.Path; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.OtherConfigKey.COMMIT_SIGNING_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING; import static net.pcal.fastback.common.logging.UserMessage.raw; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.repo.WorldIdUtils.createWorldId; import static net.pcal.fastback.common.repo.WorldIdUtils.ensureWorldHasId; import static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk; /** * @author pcal * @since 0.13.0 */ class RepoFactoryImpl implements RepoFactory { @Override public void doInit(final Path worldSaveDir, final UserLogger ulog) throws IOException { if (isGitRepo(worldSaveDir)) { ensureWorldHasId(worldSaveDir); ulog.message(styledLocalized("fastback.chat.enabled", WARNING)); return; } // If they haven't yet run 'backup init', make sure they've installed native. if (!isNativeOk(true, ulog, true)) throw new IOException(); try (final Git jgit = Git.init().setDirectory(worldSaveDir.toFile()).call()) { createWorldId(worldSaveDir); Repo repo = new RepoImpl(jgit); final Updater updater = repo.getConfig().updater(); updater.set(COMMIT_SIGNING_ENABLED, false); // because some people have it set globally updater.set(IS_NATIVE_GIT_ENABLED, true); StoredConfig config = jgit.getRepository().getConfig(); String userName = config.getString("user", null, "name"); String userEmail = config.getString("user", null, "email"); // If they don't have name/email set (as most non-git-users won't), provide synthetic values as a convenience. // Presumbably, most folks don't care. if (userName == null || userName.isEmpty() || userEmail == null || userEmail.isEmpty()) { // set from fastback world id String worldId = WorldIdUtils.getWorldIdInfo(worldSaveDir).wid().toString(); config.setString("user", null, "name", worldId); config.setString("user", null, "email", worldId + "@fastback"); } updater.save(); ulog.message(raw("Backups initialized. Run '/backup local' to do your first backup. '/backup help' for more options.")); } catch (GitAPIException e) { syslog().error("Error initializing repo", e); throw new IOException(e); } } @Override public boolean doInitCheck(Path worldSaveDir, UserLogger ulog) { if (!isGitRepo(worldSaveDir)) { ulog.message(styledRaw("Please run '/backup init' first.", ERROR)); return false; } return true; } @Override public Repo load(final Path worldSaveDir) throws IOException { final Git jgit = Git.open(worldSaveDir.toFile()); // It should already be there. But let's try to be extra sure this is there, because lots of stuff // will blow up if it's missing. ensureWorldHasId(worldSaveDir); return new RepoImpl(jgit); } @Override public boolean isGitRepo(final Path worldSaveDir) { final File dotGit = worldSaveDir.resolve(".git").toFile(); return dotGit.exists() && dotGit.isDirectory(); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/RepoImpl.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec; import net.pcal.fastback.common.repo.WorldIdUtils.WorldIdInfo; import net.pcal.fastback.common.utils.ProcessException; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.errors.NoWorkTreeException; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.util.FileUtils; import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.text.ParseException; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.List; import java.util.Set; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.BROADCAST_MESSAGE; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_LOCK_CLEANUP_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.REMOTE_NAME; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.BROADCAST; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.common.repo.PushUtils.jgit_lsRemote; import static net.pcal.fastback.common.repo.PushUtils.native_lsRemote; import static net.pcal.fastback.common.utils.EnvironmentUtils.isNativeOk; import static org.eclipse.jgit.util.FileUtils.RETRY; /** * @author pcal * @since 0.13.0 */ class RepoImpl implements Repo { // ====================================================================== // Constants static final String FASTBACK_DIR = ".fastback"; // ====================================================================== // Fields private final Git jgit; private GitConfig config; private WorldIdInfo worldIdInfo; // ====================================================================== // Constructors RepoImpl(final Git jgit) { this.jgit = requireNonNull(jgit); } // ====================================================================== // 'do' methods - implement higher-level command-oriented logic. @Override public void doCommitAndPush(final UserLogger ulog) { if (!isNativeOk(this.getConfig(), ulog, false)) return; checkIndexLock(ulog); broadcastBackupNotice(); final long start = System.currentTimeMillis(); final SnapshotId newSid; try { newSid = CommitUtils.doCommitSnapshot(this, ulog); } catch (IOException | GitAPIException | ProcessException e) { syslog().error(e); ulog.message(styledLocalized("fastback.chat.commit-failed", ERROR)); return; } try { PushUtils.doPush(newSid, this, ulog); } catch (IOException | ProcessException e) { ulog.message(styledLocalized("fastback.chat.push-failed", ERROR)); syslog().error(e); return; } ulog.message(localized("fastback.chat.backup-complete-elapsed", getDuration(start))); } @Override public void doCommitSnapshot(final UserLogger ulog) { if (!isNativeOk(this.getConfig(), ulog, false)) return; checkIndexLock(ulog); broadcastBackupNotice(); final long start = System.currentTimeMillis(); final SnapshotId newSid; try { newSid = CommitUtils.doCommitSnapshot(this, ulog); } catch (IOException | ProcessException | GitAPIException e) { ulog.message(styledLocalized("fastback.chat.commit-failed", ERROR)); syslog().error(e); return; } ulog.message(localized("fastback.chat.backup-complete-elapsed", getDuration(start))); } @Override public void doPushSnapshot(SnapshotId sid, final UserLogger ulog) { if (!this.getConfig().isSet(REMOTE_PUSH_URL)) { ulog.message(styledLocalized("fastback.chat.remote-no-url", ERROR)); return; } if (!isNativeOk(this.getConfig(), ulog, false)) return; final long start = System.currentTimeMillis(); try { PushUtils.doPush(sid, this, ulog); } catch (IOException | ProcessException e) { ulog.message(styledLocalized("fastback.chat.commit-failed", ERROR)); syslog().error(e); return; } ulog.message(UserMessage.localized("fastback.chat.push-done-elapsed", sid.getShortName(), getDuration(start))); } @Override public Collection doLocalPrune(final UserLogger ulog) throws IOException { return PruneUtils.doLocalPrune(this, ulog); } @Override public Collection doRemotePrune(final UserLogger ulog) throws IOException { return PruneUtils.doRemotePrune(this, ulog); } @Override public void doGc(final UserLogger ulog) { if (!isNativeOk(this.getConfig(), ulog, false)) return; try { ReclamationUtils.doReclamation(this, ulog); } catch (ProcessException | GitAPIException e) { ulog.message(styledLocalized("fastback.chat.gc-failed", ERROR)); syslog().error(e); } } @Override public void doRestoreLocalSnapshot(String snapshotName, UserLogger ulog) { RestoreUtils.doRestoreLocalSnapshot(snapshotName, this, ulog); } @Override public void doRestoreRemoteSnapshot(String snapshotName, UserLogger ulog) { RestoreUtils.doRestoreRemoteSnapshot(snapshotName, this, ulog); } // ====================================================================== // Other repo implementation @Override public WorldId getWorldId() throws IOException { return WorldIdUtils.getWorldIdInfo(this.getWorkTree().toPath()).wid(); } @Override public Set getLocalSnapshots() throws IOException { final JGitSupplier> refProvider = () -> { try { return jgit.branchList().call().stream().map(Ref::getName).toList(); } catch (GitAPIException e) { throw new IOException(e); } }; try { return BranchUtils.listSnapshots(this, refProvider); } catch (GitAPIException e) { throw new IOException(e); } } @Override public Set getRemoteSnapshots() throws IOException { final GitConfig conf = GitConfig.load(jgit); final String remoteName = conf.getString(REMOTE_NAME); final JGitSupplier> refProvider = () -> { try { if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) { return native_lsRemote(this.getWorkTree().toPath(), remoteName); } else { return jgit_lsRemote(this.jgit, remoteName); } } catch (GitAPIException | ProcessException e) { throw new IOException(e); } }; try { return BranchUtils.listSnapshots(this, refProvider); } catch (GitAPIException e) { throw new IOException(e); } } @Override public GitConfig getConfig() { if (this.config == null) { this.config = GitConfig.load(this.jgit); } return this.config; } @Override public File getDirectory() throws NoWorkTreeException { return this.jgit.getRepository().getDirectory(); } @Override public File getWorkTree() throws NoWorkTreeException { return this.jgit.getRepository().getWorkTree(); } @Override public void deleteRemoteBranch(String remoteBranchName) throws IOException { PruneUtils.deleteRemoteBranch(this, remoteBranchName); } @Override public void deleteLocalBranches(final List branchesToDelete) throws IOException { PruneUtils.deleteLocalBranches(this, branchesToDelete); } @Override public void close() { this.getJGit().close(); } @Override public SnapshotId createSnapshotId(String shortName) throws IOException, ParseException { return getWorldIdInfo().sidCodec().create(this.getWorldId(), shortName); } // ====================================================================== // Package-private SnapshotIdCodec getSidCodec() throws IOException { return this.getWorldIdInfo().sidCodec(); } Git getJGit() { return this.jgit; } Path getDotFasbackDir() { return this.getWorkTree().toPath().resolve(FASTBACK_DIR); } // ====================================================================== // Private private WorldIdInfo getWorldIdInfo() throws IOException { if (this.worldIdInfo == null) { this.worldIdInfo = WorldIdUtils.getWorldIdInfo(this.getWorkTree().toPath()); } return this.worldIdInfo; } private void checkIndexLock(UserLogger ulog) { final File lockFile = this.getWorkTree().toPath().resolve(".git/index.lock").toFile(); if (lockFile.exists()) { ulog.message(styledLocalized("fastback.chat.lockfile-exists", WARNING, lockFile.getAbsolutePath())); if (getConfig().getBoolean(IS_LOCK_CLEANUP_ENABLED)) { ulog.message(styledLocalized("fastback.chat.lockfile-cleanup-enabled", WARNING, "lock-cleanup-enabled = true")); try { FileUtils.delete(lockFile, RETRY); } catch (IOException e) { syslog().debug(e); // we kind of don't care } if (lockFile.exists()) { ulog.message(styledRaw("Cleanup failed. Your backup will probably not succeed.", ERROR)); } else { 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)); } } else { 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)); ulog.message(styledRaw("Proceeding with backup but it will probably not succeed.", WARNING)); } } } private static String getDuration(long since) { final Duration d = Duration.of(System.currentTimeMillis() - since, ChronoUnit.MILLIS); long seconds = d.getSeconds(); if (seconds < 60) { return String.format("%ds", seconds == 0 ? 1 : seconds); } else { return String.format("%dm %ds", seconds / 60, seconds % 60); } } private void broadcastBackupNotice() { if (!getConfig().getBoolean(BROADCAST_ENABLED)) return; final UserMessage m; final String configuredMessage = getConfig().getString(BROADCAST_MESSAGE); if (configuredMessage != null) { m = styledRaw(configuredMessage, BROADCAST); } else { m = styledLocalized("fastback.broadcast.message", BROADCAST); } mod().sendBroadcast(m); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/RestoreUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import net.pcal.fastback.common.logging.UserMessage.UserMessageStyle; import net.pcal.fastback.common.utils.FileUtils; import net.pcal.fastback.common.utils.ProcessException; import net.pcal.fastback.common.utils.ProcessUtils; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ProgressMonitor; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.function.Consumer; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.config.FastbackConfigKey.RESTORE_DIRECTORY; import static net.pcal.fastback.common.config.OtherConfigKey.REMOTE_PUSH_URL; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.NATIVE_GIT; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.logging.UserMessage.styledRaw; import static net.pcal.fastback.common.mod.Mod.mod; /** * Utilities for restoring a snapshot * * @author pcal * @since 0.13.0 */ abstract class RestoreUtils { // ====================================================================== // Package private static void doRestoreLocalSnapshot(final String snapshotNameToRestore, final RepoImpl repo, final UserLogger ulog) { doRestoreSnapshot(snapshotNameToRestore, "file://" + mod().getWorldDirectory().toAbsolutePath(), repo, ulog); } static void doRestoreRemoteSnapshot(final String snapshotNameToRestore, final RepoImpl repo, final UserLogger ulog) { final GitConfig conf = repo.getConfig(); if (!conf.isSet(REMOTE_PUSH_URL)) { ulog.message(styledLocalized("fastback.chat.remote-no-url", ERROR)); } else { doRestoreSnapshot(snapshotNameToRestore, conf.getString(REMOTE_PUSH_URL), repo, ulog); } } // ====================================================================== // Private private static void doRestoreSnapshot(final String snapshotNameToRestore, final String repoUri, final RepoImpl repo, final UserLogger ulog) { try { PreflightUtils.doPreflight(repo); final GitConfig conf = repo.getConfig(); final SnapshotId sid = repo.createSnapshotId(snapshotNameToRestore); final Path allRestoresDir = conf.isSet(RESTORE_DIRECTORY) ? Paths.get(conf.getString(RESTORE_DIRECTORY)) : mod().getDefaultRestoresDir(); final Path restoreTargetDir = getTargetDir(allRestoresDir, mod().getWorldName(), sid.getShortName()); if (conf.getBoolean(IS_NATIVE_GIT_ENABLED)) { native_restoreSnapshot(sid.getBranchName(), restoreTargetDir, repoUri, ulog); } else { jgit_restoreSnapshot(sid.getBranchName(), restoreTargetDir, repoUri, ulog); } ulog.message(localized("fastback.chat.restore-done", restoreTargetDir)); } catch (Exception e) { syslog().error(e); ulog.message(styledRaw("Restore failed. See log for details.", ERROR)); // FIXME i18n } } private static void native_restoreSnapshot(final String branchName, final Path restoreTargetDir, final String repoUri, final UserLogger ulog) throws ProcessException { final Map env = Map.of("GIT_LFS_FORCE_PROGRESS", "1"); final Consumer outputConsumer = line -> ulog.update(styledRaw(line, NATIVE_GIT)); final String restoreTargetDirStr = restoreTargetDir.toString(); syslog().debug("Cloning repo at " + repoUri); ProcessUtils.doExec(new String[]{ "git", "clone", repoUri, "--no-checkout", "--branch", branchName, "--single-branch", restoreTargetDirStr }, env, outputConsumer, outputConsumer); syslog().debug("Installing lfs locally in " + restoreTargetDirStr); ProcessUtils.doExec(new String[]{ "git", "-C", restoreTargetDirStr, "lfs", "install", "--local" }, env, outputConsumer, outputConsumer); syslog().debug("Checking out " + branchName + ", downloading lfs blobs"); ProcessUtils.doExec(new String[]{ "git", "-C", restoreTargetDirStr, "checkout", branchName }, env, outputConsumer, outputConsumer); } private static void jgit_restoreSnapshot(final String branchName, final Path restoreTargetDir, final String repoUri, final UserLogger ulog) throws IOException, GitAPIException { ulog.update(localized("fastback.hud.restore-percent", 0)); final ProgressMonitor pm = new JGitIncrementalProgressMonitor(new JGitRestoreProgressMonitor(ulog), 100); try (Git git = Git.cloneRepository().setProgressMonitor(pm).setDirectory(restoreTargetDir.toFile()). setBranchesToClone(List.of("refs/heads/" + branchName)).setBranch(branchName).setURI(repoUri).call()) { } FileUtils.rmdir(restoreTargetDir.resolve(".git")); } /** * @param allRestoresDir - general location for restorations to go. e.g., the 'saves' dir by default if client * @param worldName - name of the world * @param snapshotName - name of the snapshot being restored * @return The absolute path to the directory where the snapshot should be restored */ private static Path getTargetDir(Path allRestoresDir, String worldName, String snapshotName) { worldName = worldName.replaceAll("\\W+", ""); // strip out all non-word characters for safety Path base = allRestoresDir.resolve(worldName + "-" + snapshotName); Path candidate = base; int i = 0; while (candidate.toFile().exists()) { i++; candidate = Path.of(base + "_" + i); if (i > 1000) { throw new IllegalStateException("wat i = " + i); } } return candidate; } private static class JGitRestoreProgressMonitor extends JGitPercentageProgressMonitor { private final UserLogger ulog; public JGitRestoreProgressMonitor(UserLogger ulog) { this.ulog = requireNonNull(ulog); } @Override public void progressStart(String task) { } //remote: Finding sources //Receiving objects //Updating references //Checking out files % @Override public void progressUpdate(String task, int percentage) { final String message = task + " " + percentage + "%"; syslog().debug(message); this.ulog.update(styledRaw(message, UserMessageStyle.JGIT)); } @Override public void progressDone(String task) { } @Override public void showDuration(boolean enabled) { } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/SnapshotId.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import java.util.Date; /** * A globally-unique-ish identifier for a single backup snapshot. * * @author pcal */ public interface SnapshotId extends Comparable { // ==================================================================== // Accessors String getShortName(); Date getDate(); String getBranchName(); WorldId getWorldId(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/SnapshotIdUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ListMultimap; import net.pcal.fastback.common.repo.WorldIdUtils.WorldIdImpl; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import static net.pcal.fastback.common.logging.SystemLogger.syslog; abstract class SnapshotIdUtils { static ListMultimap getSnapshotsPerWorld(Iterable refs, SnapshotIdCodec codec) { final ListMultimap out = ArrayListMultimap.create(); for (final String ref : refs) { final String branchName = BranchUtils.getBranchName(ref); if (branchName == null) continue; try { final SnapshotId sid = codec.fromBranch(branchName); if (sid != null) out.put(sid.getWorldId(), sid); } catch (ParseException e) { syslog().warn("Ignoring unexpected branch name " + branchName); } } return out; } enum SnapshotIdCodec { V2 { private static final String SEP = "/"; @Override SnapshotId create(final WorldId wid) { final Date date = new Date(); final String shortName = DATE_FORMAT.format(date); return new SnapshotIdImpl(wid, date, shortName, getBranchName(wid, shortName)); } @Override SnapshotId create(final WorldId wid, String shortName) throws ParseException { return new SnapshotIdImpl(wid, DATE_FORMAT.parse(shortName), shortName, getBranchName(wid, shortName)); } @Override boolean isSnapshotBranchName(WorldId wid, final String branchName) { return branchName.startsWith(wid + SEP); } @Override SnapshotId fromBranch(final String rawBranchName) throws ParseException { final String[] segments = rawBranchName.split(SEP); if (segments.length != 2) { throw new ParseException("Wrong number of segments" + rawBranchName, segments.length); } final WorldId worldId = new WorldIdImpl(segments[0]); final Date date = DATE_FORMAT.parse(segments[1]); final String shortName = DATE_FORMAT.format(date); return new SnapshotIdImpl(worldId, date, shortName, rawBranchName); } private static String getBranchName(WorldId wid, String shortName) { return wid + SEP + shortName; } }, V1 { private static final String PREFIX = "snapshots"; private static final String SEP = "/"; @Override SnapshotId create(WorldId wid) { final Date date = new Date(); final String shortName = DATE_FORMAT.format(date); return new SnapshotIdImpl(wid, date, shortName, getBranchName(wid, shortName)); } @Override SnapshotId create(WorldId wid, String shortName) throws ParseException { return new SnapshotIdImpl(wid, DATE_FORMAT.parse(shortName), shortName, getBranchName(wid, shortName)); } @Override boolean isSnapshotBranchName(WorldId bid, String branchName) { return branchName.startsWith(PREFIX + SEP + bid); } //Committing snapshots/06628b24-118c-42ae-8cce-5d131a94c7ee/2022-09-12_23-24-50 @Override SnapshotId fromBranch(String rawBranchName) throws ParseException { if (!rawBranchName.startsWith(PREFIX + SEP)) { throw new ParseException("Not a snapshot branch " + rawBranchName, 0); } final String[] segments = rawBranchName.split(SEP); if (segments.length < 3) { throw new ParseException("too few segments " + rawBranchName, segments.length); } final WorldId worldUuid = new WorldIdImpl(segments[1]); final Date date = DATE_FORMAT.parse(segments[2]); final String shortName = DATE_FORMAT.format(date); return new SnapshotIdImpl(worldUuid, date, shortName, rawBranchName); } private static String getBranchName(WorldId wid, String shortName) { return PREFIX + SEP + wid + SEP + shortName; } }; static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); abstract SnapshotId create(WorldId wid); abstract SnapshotId create(WorldId worldId, String shortName) throws ParseException; abstract SnapshotId fromBranch(String rawBranchName) throws ParseException; abstract boolean isSnapshotBranchName(WorldId bid, String branchName); } public record SnapshotIdImpl(WorldId worldUuid, Date date, String shortName, String branchName) implements SnapshotId { // ==================================================================== // Accessors public String getShortName() { return shortName; } @Override public Date getDate() { return date; } public String getBranchName() { return branchName; } @Override public WorldId getWorldId() { return worldUuid; } @Override public int compareTo(final SnapshotId o) { return this.date.compareTo(o.getDate()); } @Override public String toString() { return branchName; } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/WorldId.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; /** * @author pcal * @since 0.14.0 */ public interface WorldId { String toString(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/repo/WorldIdUtils.java ================================================ package net.pcal.fastback.common.repo; import net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec; import net.pcal.fastback.common.utils.FileUtils; import java.io.File; import java.io.FileNotFoundException; import java.io.FileWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Random; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Utils for managing the world.id file, which uniquely identifies a given world * for backup purposes. The basic idea is we want to help them avoid mixing snapshots * from different worlds in the same remote repository, since that will be painful to * untangle and be pretty inefficient. * * @author pcal * @since 0.13.0 */ abstract class WorldIdUtils { // ==============================A======================================== // Constants /** * Path to where we store a short, unique-ish identifier for this world */ private static final Path WORLD_ID_PATH = Path.of(".fastback/world-id"); /** * Character length of randomly-generated world id's. 58^4 seems good * enough for our purposes. */ private static final int WORLD_ID_LENGTH = 4; /** * Path where we used to store the world id. (pre-0.15) */ @Deprecated private static final Path OLD_WORLD_UUID_PATH = Path.of(".fastback/world.uuid"); // ====================================================================== // Utils record WorldIdInfo(WorldId wid, SnapshotIdCodec sidCodec) { } /** * @return the WorldId and the SnapshotIdCodec to use. Never returns null (though the worldId might be null). */ static WorldIdInfo getWorldIdInfo(final Path worldSaveDir) throws IOException { migrateFastbackDir(worldSaveDir); { final Path idPath = worldSaveDir.resolve(WORLD_ID_PATH); if (idPath.toFile().exists()) { final WorldId wid = new WorldIdImpl(requireNonNull(Files.readString(idPath).trim())); return new WorldIdInfo(wid, SnapshotIdCodec.V2); } } { final Path uuidPath = worldSaveDir.resolve(OLD_WORLD_UUID_PATH); if (uuidPath.toFile().exists()) { final WorldId wid = new WorldIdImpl(requireNonNull(Files.readString(uuidPath).trim())); return new WorldIdInfo(wid, SnapshotIdCodec.V1); } } throw new FileNotFoundException(WORLD_ID_PATH.toString()); } static void createWorldId(final Path worldSaveDir) throws IOException { migrateFastbackDir(worldSaveDir); final Path worldIdPath = worldSaveDir.resolve(WORLD_ID_PATH).toAbsolutePath().normalize(); if (worldIdPath.toFile().exists()) { syslog().debug(worldIdPath + " already exists, skipping id creation"); return; } FileUtils.mkdirs(worldIdPath.getParent()); final String worldId = generateRandomWorldId(WORLD_ID_LENGTH); try (final FileWriter fw = new FileWriter(worldIdPath.toFile())) { fw.append(worldId); fw.append('\n'); } syslog().debug("Wrote new worldId " + worldId + " to " + worldIdPath); } static void ensureWorldHasId(final Path worldSaveDir) throws IOException { migrateFastbackDir(worldSaveDir); final Path worldIdPath = worldSaveDir.resolve(WORLD_ID_PATH).toAbsolutePath().normalize(); if (!worldIdPath.toFile().exists()) { syslog().warn("Did not find expected id file at " + worldIdPath); syslog().warn("We'll create a new one and carry on. But this indicates something weird is going on."); createWorldId(worldSaveDir); } } record WorldIdImpl(String id) implements WorldId { @Override public String toString() { return id; } } // ====================================================================== // Exposed for unit-testing static String generateRandomWorldId(int length) { final StringBuilder out = new StringBuilder(); final Random r = new Random(); for (int i = 0; i < length; i++) { out.append(BASE58_CHARS[r.nextInt(BASE58_CHARS.length)]); } return out.toString(); } // ====================================================================== // Private private static final char[] BASE58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); /** * Generate a random ID string of the given length. */ private static void migrateFastbackDir(final Path worldSaveDir) { final File oldDir = worldSaveDir.resolve("fastback").toAbsolutePath().normalize().toFile(); if (oldDir.exists()) { final File newDir = worldSaveDir.resolve(".fastback").toAbsolutePath().normalize().toFile(); if (!newDir.exists()) { if (oldDir.renameTo(newDir)) { syslog().info("moved " + oldDir + " to " + newDir); } else { syslog().error("failed to move " + oldDir + " to " + newDir); } } } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/AllRetentionPolicy.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; /** * Policy to retain all snapshots. * * @author pcal * @since 0.2.0 */ enum AllRetentionPolicy implements RetentionPolicy { INSTANCE; private static final String L10N_KEY = "fastback.retain.all.description"; @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY); } @Override public Collection getSnapshotsToPrune(Set fromSnapshots) { return Collections.emptySet(); } enum Type implements RetentionPolicyType { INSTANCE; @Override public String getName() { return "all"; } @Override public List> getParameters() { return Collections.emptyList(); } @Override public RetentionPolicy createPolicy(final Map config) { return AllRetentionPolicy.INSTANCE; } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/DailyRetentionPolicy.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import com.mojang.brigadier.arguments.IntegerArgumentType; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.time.LocalDate; import java.time.Period; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TimeZone; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Policy that retains only the last snapshot of each day, along with all snapshots in * the last n days. * * @author pcal * @since 0.2.0 */ class DailyRetentionPolicy implements RetentionPolicy { private static final String GRACE_PERIOD_DAYS = "gracePeriodDays"; private static final int DEFAULT_GRACE_PERIOD_DAYS = 3; private static final String L10N_KEY = "fastback.retain.daily.description"; private final int gracePeriod; public DailyRetentionPolicy(int gracePeriod) { this.gracePeriod = gracePeriod; } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY, gracePeriod); } @Override public Collection getSnapshotsToPrune(Set snapshots) { final LocalDate today = LocalDate.now(TimeZone.getDefault().toZoneId()); final LocalDate gracePeriodStart = today.minus(Period.ofDays(gracePeriod)); final List toPrune = new ArrayList<>(); LocalDate previousDate = null; List sortedDesending = new ArrayList<>(snapshots); Collections.sort(sortedDesending, Collections.reverseOrder()); for (final SnapshotId sid : sortedDesending) { final LocalDate currentDate = sid.getDate().toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDate(); if (previousDate != null) { if (currentDate.isAfter(gracePeriodStart)) { syslog().debug("Will retain " + sid + " because still in the grace period"); continue; } if (currentDate.equals(previousDate)) { syslog().debug("Will prune " + sid + " same day as " + currentDate); toPrune.add(sid); } else { syslog().debug("Will retain " + sid + " NOT same day as " + currentDate); } } previousDate = currentDate; } return toPrune; } /** * Retention policy that keeps only the most-recent snapshot of each day. Provides for a grace period * during which all snapshots are retained. * * @author pcal * @since 0.2.0 */ public enum DailyRetentionPolicyType implements RetentionPolicyType { INSTANCE; @Override public String getName() { return "daily"; } @Override public List> getParameters() { return List.of(new Parameter<>(GRACE_PERIOD_DAYS, IntegerArgumentType.integer(0), Integer.class)); } @Override public RetentionPolicy createPolicy(final Map config) { int gracePeriodTemp = DEFAULT_GRACE_PERIOD_DAYS; if (config != null && config.containsKey(GRACE_PERIOD_DAYS)) { try { gracePeriodTemp = Integer.parseInt(config.get(GRACE_PERIOD_DAYS)); } catch (NumberFormatException nfe) { syslog().debug("Ignoring invalid grace period " + config.get(GRACE_PERIOD_DAYS), nfe); } } final int gracePeriod = gracePeriodTemp; return new DailyRetentionPolicy(gracePeriod); } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY, "<" + GRACE_PERIOD_DAYS + ">"); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/FixedCountRetentionPolicy.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import com.mojang.brigadier.arguments.IntegerArgumentType; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Retention policy that keeps only the n most-recent snapshots. * * @author pcal * @since 0.2.0 */ class FixedCountRetentionPolicy implements RetentionPolicy { private static final int COUNT_DEFAULT = 10; private static final String POLICY_NAME = "fixed"; private static final String L10N_KEY = "fastback.retain.fixed.description"; private static final String COUNT_PARAM = "count"; private final int count; public static FixedCountRetentionPolicy create(Map config) { int count = COUNT_DEFAULT; if (config != null && config.containsKey(COUNT_PARAM)) { try { count = Integer.parseInt(config.get(COUNT_PARAM)); } catch (NumberFormatException nfe) { syslog().debug("Ignoring invalided fixed count " + config.get(COUNT_PARAM), nfe); } } return new FixedCountRetentionPolicy(count); } private FixedCountRetentionPolicy(int count) { this.count = count; } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY, this.count); } @Override public Collection getSnapshotsToPrune(Set fromSnapshots) { final List sorted = new ArrayList<>(fromSnapshots); sorted.sort(Collections.reverseOrder()); if (sorted.size() > count) { return sorted.subList(count - 1, sorted.size() - 1); } else { return Collections.emptySet(); } } enum Type implements RetentionPolicyType { INSTANCE; @Override public String getName() { return POLICY_NAME; } @Override public List> getParameters() { return List.of(new Parameter<>(COUNT_PARAM, IntegerArgumentType.integer(1), Integer.class)); } @Override public RetentionPolicy createPolicy(final Map config) { return create(config); } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY, "<" + COUNT_PARAM + ">"); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/GFSRetentionPolicy.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.time.LocalDate; import java.time.Period; import java.time.temporal.ChronoField; import java.time.temporal.IsoFields; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TimeZone; import java.util.function.Supplier; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Policy that implements a simple 'Grandfather-Father-Son' strategy. It retains * - every backup in the last 24 hours * - the latest daily backup for the past week * - the latest weekly backup for the past month * - the latest monthly backup for all past months * * @author pcal * @since 0.9.0 */ class GFSRetentionPolicy implements RetentionPolicy { private static final String L10N_KEY = "fastback.retain.gfs.description"; Supplier nowSupplier = () -> LocalDate.now(TimeZone.getDefault().toZoneId()); public GFSRetentionPolicy() { } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY); } @Override public Collection getSnapshotsToPrune(Set snapshots) { final List toPrune = new ArrayList<>(); final LocalDate now = nowSupplier.get(); final LocalDate gracePeriodStart = now.minus(Period.ofDays(2)); final LocalDate oneWeekAgo = now.minus(Period.ofDays(7)); final LocalDate oneMonthAgo = now.minus(Period.ofDays(30)); Integer currentDay = null, currentWeek = null, currentMonth = null; List sortedDesending = new ArrayList<>(snapshots); Collections.sort(sortedDesending, Collections.reverseOrder()); for (final SnapshotId sid : sortedDesending) { final LocalDate snapshotDate = sid.getDate().toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDate(); if (snapshotDate.isAfter(gracePeriodStart)) { syslog().debug("Will retain " + sid + " because still in the grace period"); } else if (snapshotDate.isAfter(oneWeekAgo)) { final int snapshotDay = snapshotDate.get(ChronoField.DAY_OF_MONTH); if (currentDay == null || currentDay != snapshotDay) { currentDay = snapshotDay; } else { toPrune.add(sid); } } else if (snapshotDate.isAfter(oneMonthAgo)) { final int snapshotWeek = snapshotDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); if (currentWeek == null || currentWeek != snapshotWeek) { currentWeek = snapshotWeek; } else { toPrune.add(sid); } } else { final int snapshotMonth = snapshotDate.get(ChronoField.MONTH_OF_YEAR); if (currentMonth == null || snapshotMonth != currentMonth) { currentMonth = snapshotMonth; } else { toPrune.add(sid); } } } return toPrune; } /** * Retention policy that keeps only the most-recent snapshot of each day. Provides for a grace period * during which all snapshots are retained. * * @author pcal * @since 0.2.0 */ public enum GFSRetentionPolicyType implements RetentionPolicyType { INSTANCE; @Override public String getName() { return "gfs"; } @Override public List> getParameters() { return Collections.emptyList(); } @Override public RetentionPolicy createPolicy(final Map config) { return new GFSRetentionPolicy(); } @Override public UserMessage getDescription() { return UserMessage.localized(L10N_KEY); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicy.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import java.util.Collection; import java.util.Set; /** * Encapsulates a policy choice about which snapshots should be kept when pruning. * * @author pcal * @since 0.2.0 */ public interface RetentionPolicy { UserMessage getDescription(); Collection getSnapshotsToPrune(final Set fromSnapshots); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicyCodec.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Singleton which can encode a RetentionPolicy into a single-line string that can easily be saved in git config. * * @author pcal * @since 0.2.0 */ public enum RetentionPolicyCodec { INSTANCE; public RetentionPolicy decodePolicy(final List availablePolicyTypes, final String encodedPolicyOriginal) { requireNonNull(availablePolicyTypes); requireNonNull(encodedPolicyOriginal); final String encodedPolicy = encodedPolicyOriginal.trim(); int firstSpace = encodedPolicy.indexOf(' '); final Map config; final String encodedTypeName; if (firstSpace == -1) { config = null; encodedTypeName = encodedPolicy.trim(); } else { encodedTypeName = encodedPolicy.substring(0, firstSpace).trim(); config = decodeMap(encodedPolicy.substring(firstSpace + 1)); } for (final RetentionPolicyType rtp : availablePolicyTypes) { if (rtp.getEncodedName().equals(encodedTypeName)) { return rtp.createPolicy(config); } } syslog().debug("Ignoring invalid retention policy " + encodedPolicy); return null; } public String encodePolicy(final RetentionPolicyType policyType, final Map config) { return policyType.getEncodedName() + " " + encodeMap(config); } // ==================================================================== // Package-private methods static String encodeMap(Map map) { final StringBuilder out = new StringBuilder(); List keys = new ArrayList<>(map.keySet()); Collections.sort(keys); boolean isFirst = true; for (final String key : keys) { if (!isValidForEncode(key)) { syslog().debug("Ignoring invalid key " + key); continue; } final String value = map.get(key); if (!isValidForEncode(value)) { syslog().debug("Ignoring invalid value " + value); continue; } if (!isFirst) { out.append(' '); } else { isFirst = false; } out.append(key); out.append('='); out.append(value); } return out.toString(); } static Map decodeMap(String encodedMap) { final Map out = new HashMap<>(); final String[] tokens = encodedMap.split(" "); for (final String token : tokens) { final String[] keyVal = token.split("="); if (keyVal.length != 2) { syslog().debug("Ignoring invalid token " + Arrays.toString(keyVal)); continue; } out.put(keyVal[0].trim(), keyVal[1].trim()); } return out; } private static boolean isValidForEncode(String keyOrVal) { return !(keyOrVal.contains("=") || keyOrVal.contains(" ")); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/retention/RetentionPolicyType.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import com.mojang.brigadier.arguments.ArgumentType; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.retention.GFSRetentionPolicy.GFSRetentionPolicyType; import java.util.List; import java.util.Map; /** * Encapsulates a general kind of retention policy. Takes simple user-supplied configuration and produces a * RetentionPolicy. * * @author pcal * @since 0.2.0 */ public interface RetentionPolicyType { static List getAvailable() { return List.of( DailyRetentionPolicy.DailyRetentionPolicyType.INSTANCE, FixedCountRetentionPolicy.Type.INSTANCE, GFSRetentionPolicyType.INSTANCE, AllRetentionPolicy.Type.INSTANCE); } record Parameter(String name, ArgumentType type, Class clazz) { } String getName(); List> getParameters(); RetentionPolicy createPolicy(Map config); default String getEncodedName() { return getName(); } default String getCommandName() { return getName(); } UserMessage getDescription(); } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/EnvironmentUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.utils; import net.minecraft.network.chat.Component; import net.pcal.fastback.common.config.GitConfig; import net.pcal.fastback.common.logging.UserLogger; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static net.pcal.fastback.common.config.FastbackConfigKey.IS_NATIVE_GIT_ENABLED; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.WARNING; import static net.pcal.fastback.common.logging.UserMessage.localized; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; import static net.pcal.fastback.common.utils.ProcessUtils.doExec; public class EnvironmentUtils { public static String getGitVersion() { return execForVersion(new String[]{"git", "--version"}); } public static String getGitLfsVersion() { return execForVersion(new String[]{"git", "lfs", "--version"}); } /** * @return true if native git is installed correctly, or if this is a legacy jgit-based backup. */ public static boolean isNativeOk(final GitConfig conf, final UserLogger ulog, final boolean verbose) { return isNativeOk(conf.getBoolean(IS_NATIVE_GIT_ENABLED), ulog, verbose); } /** * @return true if native git is installed correctly, or if this is a legacy jgit-based backup. */ public static boolean isNativeOk(boolean isNativeGitEnabled, UserLogger ulog, boolean verbose) { if (isNativeGitEnabled) { // default is true; false is undocumented and deprecated final Component notInstalled = Component.translatable("fastback.values.not-installed"); final String gitVersion = getGitVersion(); final String gitLfsVersion = getGitLfsVersion(); if (verbose) { ulog.message(localized("fastback.chat.native-info", gitVersion != null ? gitVersion : notInstalled, gitLfsVersion != null ? gitLfsVersion : notInstalled)); } if (gitVersion == null) { ulog.message(styledLocalized("fastback.chat.native-git-not-installed", ERROR, System.getenv("PATH"))); return false; } if (gitLfsVersion == null) { ulog.message(styledLocalized("fastback.chat.native-lfs-not-installed", ERROR, System.getenv("PATH"))); return false; } } else { if (verbose) ulog.message(styledLocalized("fastback.chat.native-disabled", WARNING)); } return true; } private static String execForVersion(String[] cmd) { final List stdout = new ArrayList<>(); final int exit; try { exit = doExec(cmd, Collections.emptyMap(), stdout::add, line -> { }); } catch (ProcessException e) { syslog().debug("Could not run " + String.join(" ", cmd), e); return null; } return exit == 0 ? stdout.get(0) : null; } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/Executor.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.utils; import net.pcal.fastback.common.logging.UserLogger; /** * Thin, singleton wrapper around an ExecutorService. Use this to do things in separate threads. * * @author pcal * @since 0.2.0 */ public interface Executor { static Executor executor() { return Singleton.INSTANCE; } // TODO kill UserLogger param and throw Blocking exception instead void execute(final ExecutionLock lock, final UserLogger ulog, final Runnable runnable); int getActiveCount(); void start(); void stop(); enum ExecutionLock { NONE, WRITE_CONFIG, WRITE, } class Singleton { private static final Executor INSTANCE = new ExecutorImpl(); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/ExecutorImpl.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.utils; import net.pcal.fastback.common.logging.UserLogger; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.SystemLogger.syslog; import static net.pcal.fastback.common.logging.UserMessage.UserMessageStyle.ERROR; import static net.pcal.fastback.common.logging.UserMessage.styledLocalized; /** * @author pcal * @since 0.2.0 */ class ExecutorImpl implements Executor { private ThreadPoolExecutor executor = null; private Future exclusiveFuture = null; @Override public void execute(ExecutionLock lock, UserLogger ulog, Runnable runnable) { requireNonNull(lock, "lock"); if (this.executor == null) throw new IllegalStateException("Executor not started"); switch (lock) { case NONE: case WRITE_CONFIG: // revisit this this.executor.submit(runnable); break; case WRITE: if (this.exclusiveFuture != null && !this.exclusiveFuture.isDone()) { ulog.message(styledLocalized("fastback.chat.thread-busy", ERROR)); } else { syslog().debug("executing " + runnable); this.exclusiveFuture = this.executor.submit(runnable); } break; default: throw new IllegalStateException(); } } @Override public int getActiveCount() { return this.executor.getActiveCount(); } @Override public void start() { this.executor = new ThreadPoolExecutor(0, 3, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); } @Override public void stop() { shutdownExecutor(this.executor); this.executor = null; } /** * Lifted straight from the docs: * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html */ private static void shutdownExecutor(final ExecutorService pool) { pool.shutdown(); // Disable new tasks from being submitted try { // Wait a while for existing tasks to terminate if (!pool.awaitTermination(5, TimeUnit.MINUTES)) { pool.shutdownNow(); // Cancel currently executing tasks // Wait a while for tasks to respond to being cancelled if (!pool.awaitTermination(5, TimeUnit.MINUTES)) System.err.println("Pool did not terminate"); } } catch (InterruptedException ie) { // (Re-)Cancel if current thread also interrupted pool.shutdownNow(); // Preserve interrupt status Thread.currentThread().interrupt(); } } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/FileUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.utils; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; public class FileUtils { public static void mkdirs(final Path path) throws IOException { final File file = path.toFile(); if (file.exists()) { if (!file.isDirectory()) { throw new IOException("Cannot create directory because file exists at " + path); } } else { file.mkdirs(); if (!file.exists() || !file.isDirectory()) { throw new IOException("Failed to create directory at " + path); } } } public static void rmdir(final Path path) throws IOException { org.apache.commons.io.FileUtils.deleteDirectory(path.toFile()); } public static void writeResourceToFile(String resourcePath, Path targetFile) throws IOException { final String rawResource; try (InputStream in = FileUtils.class.getClassLoader().getResourceAsStream(resourcePath)) { if (in == null) { throw new FileNotFoundException("Unable to load resource " + resourcePath); // wat } rawResource = new String(in.readAllBytes(), StandardCharsets.UTF_8); } mkdirs(targetFile.getParent()); Files.writeString(targetFile, rawResource); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/ProcessException.java ================================================ package net.pcal.fastback.common.utils; import java.util.List; import java.util.function.Consumer; import static java.util.Objects.requireNonNull; /** * Thrown when an attempt to execute an external process fails. * * @author pcal * @since 0.15.0 */ public class ProcessException extends Exception { private final List processOutput; ProcessException(String[] args, final int exitCode, final List processOutput, Throwable nested) { super("Exit " + exitCode + " when executing: " + String.join(" ", args), nested); this.processOutput = requireNonNull(processOutput); } ProcessException(String[] args, final int exitCode, final List stdoutLines) { super("Exit " + exitCode + " when executing: " + String.join(" ", args)); this.processOutput = requireNonNull(stdoutLines); } /** * Copies the original process to the given line consumer. */ public void writeProcessOutput(Consumer consumer) { for (String line : processOutput) consumer.accept(line); } } ================================================ FILE: common/src/main/java/net/pcal/fastback/common/utils/ProcessUtils.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.utils; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Consumer; import static java.util.Objects.requireNonNull; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Utilities for executing other processes (git, mainly). * * @author pcal * @since 0.15.0 */ public class ProcessUtils { public static int doExec(String[] args, final Map envOriginal, Consumer stdoutSink, Consumer stderrSink) throws ProcessException { return doExec(args, envOriginal, stdoutSink, stderrSink, true); } public static int doExec(final String[] args, final Map envOriginal, final Consumer stdoutSink, final Consumer stderrSink, boolean throwOnNonZero) throws ProcessException { syslog().debug("Executing " + String.join(" ", args)); final ProcessBuilder pb = new ProcessBuilder(args); final Map env = pb.environment(); // Output a few values that are important for debugging; don't indiscriminately dump everything or someone's going // to end up uploading a bunch of passwords into pastebin. syslog().debug("PATH: " + env.get("PATH")); if (System.getProperty("os.name").toLowerCase().contains("windows")) { syslog().debug("HOME: " + env.get("USERPROFILE")); syslog().debug("USER: " + env.get("USERNAME")); } else { syslog().debug("HOME: " + env.get("HOME")); syslog().debug("USER: " + env.get("USER")); } final List errorBuffer = new ArrayList<>(); final Consumer stdout = line -> { syslog().debug("[STDOUT] " + line); stdoutSink.accept(line); errorBuffer.add("[STDOUT] " + line); }; final Consumer stderr = line -> { syslog().debug("[STDERR] " + line); stderrSink.accept(line); errorBuffer.add("[STDERR] " + line); }; final int exit; try { final Process p = pb.start(); exit = drainAndWait(p, new LineWriter(stdout), new LineWriter(stderr)); } catch (IOException | InterruptedException e) { throw new ProcessException(args, 0, errorBuffer, e); } if (throwOnNonZero && exit != 0) { throw new ProcessException(args, exit, errorBuffer); } return exit; } // ====================================================================== // Private private static class LineWriter extends Writer { private final Consumer sink; private final StringBuilder buffer = new StringBuilder(); private LineWriter(final Consumer sink) { this.sink = requireNonNull(sink); } @Override public void write(char[] cbuf, int off, int len) { buffer.append(cbuf, off, len); outputLines(); } private void outputLines() { int lineStart = 0, lineEnd; while ((lineEnd = findLineEnd(buffer, lineStart)) != -1) { final String line = buffer.substring(lineStart, lineEnd).trim(); if (line.length() > 0) this.sink.accept(line); lineStart = lineEnd + 1; } if (lineStart != 0) { buffer.delete(0, lineStart); } } private static int findLineEnd(StringBuilder buffer, int lineStart) { int newLine = buffer.indexOf("\n", lineStart); int carriage = buffer.indexOf("\r", lineStart); if (newLine == -1) return carriage; if (carriage == -1) return newLine; return Math.min(newLine, carriage); } @Override public void flush() { outputLines(); if (buffer.length() > 0) { this.sink.accept(buffer.toString()); } } @Override public void close() { } } private static int drainAndWait(Process process, Writer stdoutSink, Writer stderrSink) throws IOException, InterruptedException { Reader stdoutReader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8); Reader stderrReader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8); char[] buffer = new char[1024]; while (true) { boolean readAny = false; // // process stdin // if (stdoutReader != null && stdoutReader.ready()) { int read = stdoutReader.read(buffer, 0, buffer.length); if (read < 0) { stdoutReader = null; } else if (read > 0) { readAny = true; stdoutSink.write(buffer, 0, read); } } // // process stdout // if (stderrReader != null && stderrReader.ready()) { int read = stderrReader.read(buffer, 0, buffer.length); if (read < 0) { stderrReader = null; } else if (read > 0) { readAny = true; stderrSink.write(buffer, 0, read); } } if (readAny) { continue; } else if (!process.isAlive()) { return process.exitValue(); } else { try { Thread.sleep(10); // FIXME add timeout? } catch (InterruptedException ie) { process.destroy(); throw ie; } } } } } ================================================ FILE: common/src/main/resources/assets/fastback/lang/de_de.json ================================================ { "fastback.help.command.create-file-remote" : "Erstellt ein Remote-Backup-Ziel im Dateisystem.", "fastback.help.command.delete" : "Löscht einen individuellen Snapshot.", "fastback.help.command.disable" : "Deaktiviert Backups für diese Welt.", "fastback.help.command.enable" : "Aktiviert lokale Backups für diese Welt.", "fastback.help.command.full" : "Führt sofort ein lokales und remotes Backup aus.", "fastback.help.command.gc" : "Führt eine Garbage Collection aus um Festplattenspeicher freizugeben.", "fastback.help.command.help" : "Zeigt Hilfe zu Befehlen an.", "fastback.help.command.info" : "Informationen zum aktuellen Status und den Einstellungen des aktuellen Backups.", "fastback.help.command.init" : "Initialisiert FastBack für die aktuelle Welt. Dieser Befehl muss als erstes ausgeführt werden.", "fastback.help.command.list" : "Zeigt eine Liste aller Snapshots dieser Welt.", "fastback.help.command.local" : "Erstellt sofort ein lokales Backup.", "fastback.help.command.prune" : "Löscht alte Snapshots anhand der Aufbewahrungsrichtlinie.", "fastback.help.command.push" : "Überträgt ein Snapshot an das Remote-Backup-Ziel.", "fastback.help.command.remote-delete" : "Löscht ein Snapshot im Remote-Backup-Ziel.", "fastback.help.command.remote-list" : "Remote Snapshots auflisten.", "fastback.help.command.remote-prune" : "Löscht alte Snapshots im Remote-Backup-Ziel anhand der Aufbewahrungsrichtlinie.", "fastback.help.command.remote-restore" : "Stellt ein Remote Snapshot wieder her.", "fastback.help.command.restore" : "Stellt ein Backup Snapshot wieder her.", "fastback.help.command.set" : "Ändert Einstellungsparameter.", "fastback.help.command.set-autoback-action" : "Legt die Aktion fest, die bei automatischen Backups ausgeführt wird.", "fastback.help.command.set-autoback-wait" : "Legt die Mindestdauer zwischen Backups in Minuten fest.", "fastback.help.command.set-remote" : "Legt die URL für Remote-Backups fest.", "fastback.help.command.set-remote-retention" : "Legt die Aufbewahrungsrichtlinie für Remote-Backups fest.", "fastback.help.command.set-retention" : "Legt die Aufbewahrungsrichtlinie fest.", "fastback.help.command.set-shutdown-action" : "Legt die Aktion beim Herunterfahren fest.", "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", "fastback.help.suggest-init" : "\nUm zu starten, führe '/backup init' aus", "fastback.help.backup-start" : "Backup gestartet für %s", "fastback.chat.backup-complete" : "Backup abgeschlossen.", "fastback.chat.backup-complete-elapsed" : "Backup abgeschlossen. Benötigte Zeit: %s", "fastback.chat.create-file-remote-created" : "Git-Repository erstellt unter %s\nRemote-Backups aktiviert unter:\n%s", "fastback.chat.create-file-remote-dir-exists" : "Verzeichnis existiert bereits:\n%s", "fastback.chat.delete-start" : "Lösche Snapshot %s %s", "fastback.chat.delete-done" : "Snapshot %s gelöscht", "fastback.chat.disable-already-disabled" : "Backups sind bereits deaktiviert", "fastback.chat.gc-done" : "Garbage Collection abgeschlossen. %s freigegeben.", "fastback.chat.gc-done-no-reclaim" : "Garbage Collection abgeschlossen.", "fastback.chat.gc-failed" : "Garbage collection fehlgeschlagen. Details können dem Log entnommen werden.", "fastback.chat.commit-failed" : "Backup fehlgeschlagen. Details können dem Log entnommen werden.", "fastback.chat.commit-start" : "Erstelle Backup Snapshot %s", "fastback.chat.commit-complete" : "Commit abgeschlossen.", "fastback.chat.info-autoback-action" : "Autoback Aktion: %s", "fastback.chat.info-autoback-wait" : "Autoback Wartezeit: %s Minuten", "fastback.chat.info-backup-size" : "Lokale Backup Größe : %s", "fastback.chat.info-fastback-version" : "FastBack Version: %s", "fastback.chat.info-header" : "\nFastBack Info\n-------------", "fastback.chat.info-local-disabled" : "Lokale Backups: deaktiviert", "fastback.chat.info-local-enabled" : "Lokale Backups: aktiviert", "fastback.chat.info-remote-url" : "Remote URL: %s", "fastback.chat.info-shutdown-action" : "Aktion beim Herunterfahren: %s", "fastback.chat.info-uuid" : "Backup UUID: %s", "fastback.chat.info-world-size" : "Weltgröße: %s", "fastback.chat.internal-error" : "Während des Backups ist ein unerwarteter Fehler aufgetreten. Details können dem Log entnommen werden.", "fastback.chat.invalid-input" : "Ungültige Eingabe: %s", "fastback.chat.list-local-snapshots-header" : "Lokale Snapshots:", "fastback.chat.lockfile-exists" : "Ein Backup Lock-File existiert. %s", "fastback.chat.lockfile-cleanup-enabled" : "%s, versuche Backup-Lockfiles zu bereinigen...", "fastback.chat.missing-argument" : "Fehlendes Argument: %s", "fastback.chat.no-change" : "Keine Änderung.", "fastback.chat.enabled" : "Für diese Welt sind Backups bereits aktiviert.", "fastback.chat.native-disabled" : "Natives Git ist deaktiviert. FastBack wird JGit für Backups verwenden.", "fastback.chat.native-info" : "git: %s\ngit-lfs: %s", "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.", "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.", "fastback.chat.not-enabled" : "Backups sind für diese Welt nicht aktiviert. Führe '/backup init' aus", "fastback.chat.ok" : "OK", "fastback.chat.prune-done" : "%s Snapshots bereinigt.", "fastback.chat.prune-no-default" : "Es ist keine Standard-Aufbewahrungsrichtlinie konfiguriert. Bitte '/backup set retention-policy' ausführen", "fastback.chat.prune-suggest-gc" : "Führe '/backup gc' aus um Festplattenspeicher freizugeben.", "fastback.chat.push-failed" : "Das lokale Backup war erfolgreich, aber das Remote-Backup ist fehlgeschlagen. Details können dem Log entnommen werden.", "fastback.chat.push-started" : "Lade Backup hoch zu %s...", "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.", "fastback.chat.push-done" : "Backup hochgeladen zu %s", "fastback.chat.push-done-elapsed" : "Backup hochgeladen zu %s. Benötigte Zeit: %s", "fastback.chat.remote-delete-done" : "Remote Snapshot %s wurde gelöscht", "fastback.chat.remote-enabled" : "Remote Backups wurden aktiviert mit Ziel:\n%s", "fastback.chat.remote-how-to-enable-no-url" : "Führe '/backup set remote-url ' aus um Remote-Backups zu aktivieren.", "fastback.chat.remote-list-done" : "%d Snapshots gefunden in %s", "fastback.chat.remote-no-url" : "Es ist kein Remote-URL konfiguriert.\nFühre '/backup set remote-url ' aus", "fastback.chat.remote-set" : "Remote-Backup-URL gesetzt auf:\n%s", "fastback.chat.remote-retention-policy-none" : "Kein Remote-Snapshot-Aufbewahrungsrichtlinie konfiguriert.", "fastback.chat.remote-retention-policy-not-set": "Kein Remote-Aufbewahrungsrichtlinie konfiguriert. Führe '/backup set remote-retention-policy' aus", "fastback.chat.remote-retention-policy-set" : "Remote-Snapshot-Aufbewahrungsrichtlinie gesetzt auf:", "fastback.chat.restore-done" : "Snapshot wiederhergestellt zu \n%s", "fastback.chat.restore-nosuch" : "Snapshot %s wurde nicht gefunden", "fastback.chat.retention-policy-none" : "Keine Snapshot Aufbewahrungsrichtlinie konfiguriert.", "fastback.chat.retention-policy-not-set" : "Keine Aufbewahrungsrichtlinie konfiguriert. Führe '/backup set retention-policy' aus", "fastback.chat.retention-policy-set" : "Snapshot Aufbewahrungsrichtlinie gesetzt auf:", "fastback.chat.thread-busy" : "Ein anderes Backup läuft gerade. Bitte warte, bis das andere Backup abgeschlossen ist und versuche es dann erneut.", "fastback.chat.thread-waiting" : "Warte, bis aktuelles Backup abgeschlossen ist...", "fastback.chat.world-save" : "Speichere Welt bevor das Backup gestartet wird...", "fastback.hud.local-saving" : "Sichere lokales Backup...", "fastback.hud.prune-started" : "Optimiere (pruning)...", "fastback.message.backing-up" : "Backup wird erstellt...", "fastback.broadcast.message" : "Der Server startet ein Backup.", "fastback.retain.all.description" : "Behalte alle Snapshots, keine Bereinigung.", "fastback.retain.fixed.description" : "Fixed: Behalte nur die %s neuesten Snapshots.", "fastback.retain.daily.description" : "Daily: Behalte das neueste Snapshot für jeden Tag und alle Snapshots der letzten %s Tage", "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", "fastback.values.disabled" : "deaktiviert", "fastback.values.enabled" : "aktiviert", "fastback.values.none" : "keine", "fastback.values.not-installed" : "nicht installiert" } ================================================ FILE: common/src/main/resources/assets/fastback/lang/en_us.json ================================================ { "fastback.help.command.create-file-remote" : "Create a remote backup target on the file system.", "fastback.help.command.delete" : "Delete an individual snapshot.", "fastback.help.command.disable" : "Disable backups on this world.", "fastback.help.command.enable" : "Enable local backups backups on this world.", "fastback.help.command.full" : "Perform a local and remote backup immediately.", "fastback.help.command.gc" : "Run garbage collection to free up disk space.", "fastback.help.command.help" : "Get help on commands.", "fastback.help.command.info" : "Info about current backup state and settings.", "fastback.help.command.init" : "Initialize fastback for the current world. Run this first.", "fastback.help.command.list" : "List backup snapshots for this world.", "fastback.help.command.local" : "Perform a local backup immediately.", "fastback.help.command.prune" : "Delete old snapshots according to the retention policy.", "fastback.help.command.push" : "Push a snapshot to the remote.", "fastback.help.command.remote-delete" : "Delete a remote snapshot.", "fastback.help.command.remote-list" : "List remote snapshots.", "fastback.help.command.remote-prune" : "Delete old snapshots from the remote backup according to the remote retention policy.", "fastback.help.command.remote-restore" : "Restore a remote snapshot.", "fastback.help.command.restore" : "Restore a backup snapshot.", "fastback.help.command.set" : "Change configuration settings.", "fastback.help.command.set-autoback-action" : "Set an action to perform during auto-backups.", "fastback.help.command.set-autoback-wait" : "Set the minimum number of minutes to wait between auto-backups.", "fastback.help.command.set-remote" : "Set the url for remote backups.", "fastback.help.command.set-remote-retention" : "Set snapshot retention policy for the remote backup.", "fastback.help.command.set-retention" : "Set snapshot retention policy.", "fastback.help.command.set-shutdown-action" : "Set an action to perform on shutdown.", "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", "fastback.help.suggest-init" : "\nTo get started, type '/backup init'", "fastback.help.backup-start" : "Backing up %s", "fastback.chat.backup-complete" : "Backup complete.", "fastback.chat.backup-complete-elapsed" : "Backup complete. Time elapsed: %s", "fastback.chat.create-file-remote-created" : "Git repository created at %s\nRemote backups enabled to:\n%s", "fastback.chat.create-file-remote-dir-exists" : "Directory already exists:\n%s", "fastback.chat.delete-start" : "Deleting snapshot %s %s", "fastback.chat.delete-done" : "Deleted snapshot %s", "fastback.chat.disable-already-disabled" : "Backups already disabled.", "fastback.chat.gc-done" : "Garbage collection complete. %s reclaimed.", "fastback.chat.gc-done-no-reclaim" : "Garbage collection complete.", "fastback.chat.gc-failed" : "Garbage collection failed. See log for details.", "fastback.chat.commit-failed" : "Backup failed. See log for details.", "fastback.chat.commit-start" : "Creating backup snapshot %s", "fastback.chat.commit-complete" : "Commit Complete.", "fastback.chat.info-autoback-action" : "Autoback action: %s", "fastback.chat.info-autoback-wait" : "Autoback wait: %s minutes", "fastback.chat.info-backup-size" : "Local backup size: %s", "fastback.chat.info-fastback-version" : "FastBack version: %s", "fastback.chat.info-header" : "\nFastBack Info\n-------------", "fastback.chat.info-local-disabled" : "Local backup: disabled", "fastback.chat.info-local-enabled" : "Local backup: enabled", "fastback.chat.info-remote-url" : "Remote URL: %s", "fastback.chat.info-shutdown-action" : "Shutdown action: %s", "fastback.chat.info-uuid" : "Backup UUID: %s", "fastback.chat.info-world-size" : "World size: %s", "fastback.chat.internal-error" : "An unexpected backup error occurred. See log for details.", "fastback.chat.invalid-input" : "Invalid input: %s", "fastback.chat.list-local-snapshots-header" : "Local snapshots:", "fastback.chat.lockfile-exists" : "Backup lockfile exists. %s", "fastback.chat.lockfile-cleanup-enabled" : "%s, Attempting to clean up lockfile...", "fastback.chat.missing-argument" : "Missing argument: %s", "fastback.chat.no-change" : "No change.", "fastback.chat.enabled" : "Backups are already enabled on this world.", "fastback.chat.native-disabled" : "Native Git is disabled. FastBack will use JGit for backups.", "fastback.chat.native-info" : "git: %s\ngit-lfs: %s", "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.", "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.", "fastback.chat.not-enabled" : "Backups are not enabled on this world. Run '/backup init'", "fastback.chat.ok" : "ok", "fastback.chat.prune-done" : "Pruned %s snapshots.", "fastback.chat.prune-no-default" : "No default pruning policy configured. Please run /backup set retention-policy", "fastback.chat.prune-suggest-gc" : "Run /backup gc to reclaim disk space.", "fastback.chat.push-failed" : "Local backup succeeded but remote backup failed. See log for details.", "fastback.chat.push-started" : "Uploading backup to %s...", "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.", "fastback.chat.push-done" : "Backup uploaded to %s", "fastback.chat.push-done-elapsed" : "Backup uploaded to %s. Time elapsed: %s", "fastback.chat.remote-delete-done" : "Deleted remote snapshot %s", "fastback.chat.remote-enabled" : "Enabled remote backups to:\n%s", "fastback.chat.remote-how-to-enable-no-url" : "Run '/backup set remote-url ' to enable remote backups.", "fastback.chat.remote-list-done" : "%d snapshots found at %s", "fastback.chat.remote-no-url" : "No remote URL is set.\nRun '/backup set remote-url '", "fastback.chat.remote-set" : "Remote backup URL set to:\n%s", "fastback.chat.remote-retention-policy-none" : "No remote snapshot retention policy set.", "fastback.chat.remote-retention-policy-not-set": "No remote retention policy set. Run /backup set remote-retention-policy", "fastback.chat.remote-retention-policy-set" : "Remote snapshot retention policy set to:", "fastback.chat.restore-done" : "Snapshot restored to \n%s", "fastback.chat.restore-nosuch" : "No such snapshot %s", "fastback.chat.retention-policy-none" : "No snapshot retention policy set.", "fastback.chat.retention-policy-not-set" : "No retention policy set. Run /backup set retention-policy", "fastback.chat.retention-policy-set" : "Snapshot retention policy set to:", "fastback.chat.thread-busy" : "Another backup task is currently running. Please wait for it to finish and try again.", "fastback.chat.thread-waiting" : "Waiting for current backup tasks to complete...", "fastback.chat.world-save" : "Saving world before backup...", "fastback.hud.local-saving" : "Saving local backup...", "fastback.hud.prune-started" : "Pruning...", "fastback.message.backing-up" : "Backing up...", "fastback.broadcast.message" : "The server is starting a backup.", "fastback.retain.all.description" : "Retain all snapshots; never prune.", "fastback.retain.fixed.description" : "Fixed: Keep only the %s most-recent snapshots.", "fastback.retain.daily.description" : "Daily: Keep the last snapshot from each day, plus all snapshots from the last %s days", "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", "fastback.values.disabled" : "disabled", "fastback.values.enabled" : "enabled", "fastback.values.none" : "none", "fastback.values.not-installed" : "not installed" } ================================================ FILE: common/src/main/resources/assets/fastback/lang/es_es.json ================================================ { "fastback.help.command.create-file-remote" : "Crea un repositorio de Git para las copias de seguridad en el sistema.", "fastback.help.command.delete" : "Elimina una instantánea individual.", "fastback.help.command.disable" : "Desactiva las copias de seguridad en este mundo.", "fastback.help.command.enable" : "Activa las copias de seguridad locales en este mundo.", "fastback.help.command.full" : "Reliza una copia de seguridad (local y remota) inmediatamente.", "fastback.help.command.gc" : "Limpia archivos innecesarios para liberar espacio en el disco.", "fastback.help.command.help" : "Muestra ayuda sobre los comandos.", "fastback.help.command.info" : "Información sobre los ajustes y estado actual de las copias de seguridad.", "fastback.help.command.list" : "Enumera las instantáneas de este mundo.", "fastback.help.command.local" : "Reliza una copia de seguridad local inmediatamente.", "fastback.help.command.prune" : "Elimina instantáneas antiguas de acuerdo a la política de retención.", "fastback.help.command.remote-delete" : "Elimina una instantánea remota.", "fastback.help.command.remote-list" : "Enumera las instantáneas remotas.", "fastback.help.command.remote-prune" : "Elimina instantáneas remotas antiguas de acuerdo a la política de retención remota.", "fastback.help.command.remote-restore" : "Restaura una instantánea remota.", "fastback.help.command.restore" : "Restaura una instantánea.", "fastback.help.command.set" : "Cambia los ajustes.", "fastback.help.command.set-autoback-action" : "Establece una acción a ejecutar durante las copias de seguridad automáticas.", "fastback.help.command.set-autoback-wait" : "Establece el tiempo mínimo entre copias de seguridad automáticas en minutos.", "fastback.help.command.set-remote" : "Establece la URL para las copias de seguridad remotas.", "fastback.help.command.set-remote-retention" : "Establece la política de retención de instantáneas remotas.", "fastback.help.command.set-retention" : "Establece la política de retención de copias de seguridad remotas.", "fastback.help.command.set-shutdown-action" : "Establece una acción a ejecutar al cerrar.", "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", "fastback.help.suggest-init" : "\nPara empezar, ejecuta '/backup init'", "fastback.chat.backup-complete" : "Copia de seguridad completa.", "fastback.chat.backup-complete-elapsed" : "Copia de seguridad completa. Tiempo transcurrido: %s", "fastback.chat.create-file-remote-created" : "Repositorio Git creado en %s\nCopias de seguridad remotas activas para:\n%s", "fastback.chat.create-file-remote-dir-exists" : "El directorio ya existe:\n%s", "fastback.chat.delete-done" : "Borrada instantánea %s", "fastback.chat.disable-already-disabled" : "Las copias de seguridad ya están desactivadas.", "fastback.chat.gc-done" : "Limpieza completa. %s reclamados.", "fastback.chat.commit-failed" : "La instantánea ha fallado. Mira el registro para más detalles.", "fastback.chat.commit-start" : "Creando instantánea %s", "fastback.chat.info-autoback-action" : "Autoback action: %s", "fastback.chat.info-autoback-wait" : "Autoback wait: %s minutes", "fastback.chat.info-backup-size" : "Tamaño de la copia de seguridad local: %s", "fastback.chat.info-fastback-version" : "Versión de FastBack: %s", "fastback.chat.info-header" : "\nInformación de FastBack\n-------------", "fastback.chat.info-local-disabled" : "Copias de seguridad locales: desactivadas", "fastback.chat.info-local-enabled" : "Copias de seguridad locales: activadas", "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.", "fastback.chat.info-remote-url" : "URL remota: %s", "fastback.chat.info-shutdown-action" : "Acción al cerrar: %s", "fastback.chat.info-uuid" : "UUID de la copia de seguridad: %s", "fastback.chat.info-world-size" : "Tamaño del mundo: %s", "fastback.chat.internal-error" : "Ha ocurrido un error inesperado en la copia de seguridad. Mira el registro para más detalles.", "fastback.chat.invalid-input" : "Datos inválidos: %s", "fastback.chat.list-local-snapshots-header" : "Instantáneas locales:", "fastback.chat.missing-argument" : "Argumentos necesarios: %s", "fastback.chat.not-enabled" : "Las copias de seguridad no están activadas en este mundo. Ejecuta '/backup init'", "fastback.chat.ok" : "ok", "fastback.chat.prune-done" : "Limpiadas %s instantáneas.", "fastback.chat.prune-no-default" : "No hay política de borrado de instantáneas antiguas configurada. Por favor ejecuta /backup set retention-policy", "fastback.chat.prune-suggest-gc" : "Ejecuta /backup gc para reclamar espacio en el disco.", "fastback.chat.push-failed" : "Copia de seguridad completa, pero error al subir al entorno remoto. Mira el registro para más detalles.", "fastback.chat.push-started" : "Subiendo copia de seguridad a %s...", "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.", "fastback.chat.remote-delete-done" : "Eliminada instantánea remota %s", "fastback.chat.remote-enabled" : "Activadas copias de seguridad remotas para:\n%s", "fastback.chat.remote-how-to-enable-no-url" : "Ejecuta '/backup set remote-url ' para activar las copias de seguridad remotas.", "fastback.chat.remote-list-done" : "%d instantáneas encontradas en %s", "fastback.chat.remote-no-url" : "No hay una URL remota establecida.\nEjecuta '/backup set remote-url '", "fastback.chat.remote-set" : "URL remota establecida a:\n%s", "fastback.chat.remote-retention-policy-none" : "No hay política de retención de instantáneas remotas configurada.", "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", "fastback.chat.remote-retention-policy-set" : "Política de retención de instantáneas remotas establecida a:", "fastback.chat.restore-done" : "Instantánea establecida a \n%s", "fastback.chat.restore-nosuch" : "No se encuentra la instantánea %s", "fastback.chat.retention-policy-none" : "No hay política de retención de instantáneas configurada.", "fastback.chat.retention-policy-not-set" : "No hay política de retención de copias de seguridad configurada. Ejectura /backup set retention-policy", "fastback.chat.retention-policy-set" : "Política de retención de instantáneas establecida a:", "fastback.chat.thread-busy" : "Ya se está ejecutando otra copia de seguridad. Por favor, espera a que acabe y vuelve a intentarlo.", "fastback.chat.thread-waiting" : "Esperando a que la copia de seguridad en proceso acabe...", "fastback.chat.world-save" : "Guardando el mundo antes de la copia de seguridad...", "fastback.hud.local-saving" : "Guardando copia de seguridad local...", "fastback.hud.prune-started" : "Limpiando copas de seguridad...", "fastback.message.backing-up" : "Realizando copia de seguridad...", "fastback.broadcast.message" : "El servidor está empezando una copia de seguridad.", "fastback.retain.all.description" : "Guarda todas las instantáneas: nunca las borres.", "fastback.retain.fixed.description" : "Fixed: Mantén las %s instantáneas más recientes.", "fastback.retain.daily.description" : "Daily: Mantén las últimas instantáneas de cada día, más las de los últimos %s días", "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", "fastback.values.disabled" : "deshabilitado", "fastback.values.enabled" : "habilitado", "fastback.values.none" : "ninguno/a", "fastback.values.not-installed" : "no instalado" } ================================================ FILE: common/src/main/resources/assets/fastback/lang/ja_jp.json ================================================ { "fastback.help.command.create-file-remote" : "ファイルシステムにリモートバックアップターゲットを作成します.", "fastback.help.command.delete" : "個々のスナップショットを削除します", "fastback.help.command.disable" : "このワールドでバックアップを無効にする", "fastback.help.command.enable" : "このワールドでローカルバックアップを有効にする", "fastback.help.command.full" : "直ちにローカルとリモートのバックアップを実行", "fastback.help.command.gc" : "ガベージコレクションを実行してディスク領域を解放する", "fastback.help.command.help" : "コマンドのヘルプを表示", "fastback.help.command.info" : "現在のバックアップ状態と設定に関する情報を表示", "fastback.help.command.init" : "このワールドで fastback を初期化します。これを最初に実行してください", "fastback.help.command.list" : "このワールドのバックアップスナップショットの一覧を表示", "fastback.help.command.local" : "直ちにローカルバックアップを実行", "fastback.help.command.prune" : "リテンションポリシーに従って古いスナップショットを削除", "fastback.help.command.push" : "スナップショットをリモートにプッシュ", "fastback.help.command.remote-delete" : "リモートスナップショットを削除", "fastback.help.command.remote-list" : "リモートスナップショットの一覧を表示", "fastback.help.command.remote-prune" : "リモートリテンションポリシーに従って、リモートバックアップから古いスナップショットを削除", "fastback.help.command.remote-restore" : "リモートスナップショットを復元", "fastback.help.command.restore" : "バックアップスナップショットを復元", "fastback.help.command.set" : "構成設定の変更", "fastback.help.command.set-autoback-action" : "自動バックアップ中に実行するアクションを設定", "fastback.help.command.set-autoback-wait" : "自動バックアップを行う間隔を設定します", "fastback.help.command.set-remote" : "リモートバックアップのURLを設定", "fastback.help.command.set-remote-retention" : "リモートバックアップの、スナップショットリテンションポリシーを設定", "fastback.help.command.set-retention" : "スナップショットリテンションポリシーを設定", "fastback.help.command.set-shutdown-action" : "シャットダウン時に実行するアクションを設定", "fastback.help.subcommands" : "使用可能なサブコマンド:\n%s\nサブコマンドの詳細を表示するには\n/backup help [subcommand] を実行してください\nまたは、こちらを参照してください https://pcal43.github.io/fastback", "fastback.help.suggest-init" : "\n開始するには '/backup init' を実行してください", "fastback.help.backup-start" : "バックアップ中 %s", "fastback.chat.backup-complete" : "バックアップ完了", "fastback.chat.backup-complete-elapsed" : "バックアップ完了 経過時間: %s", "fastback.chat.create-file-remote-created" : "Gitリポジトリが %s に作成されました\nリモートバックアップが有効になりました。\n保存先: %s", "fastback.chat.create-file-remote-dir-exists" : "ディレクトリはすでに存在します:\n%s", "fastback.chat.delete-start" : "スナップショットを削除しています %s %s", "fastback.chat.delete-done" : "スナップショットを削除しました %s", "fastback.chat.disable-already-disabled" : "バックアップはすでに無効になっています", "fastback.chat.gc-done" : "ガベージコレクション完了 %s reclaimed.", "fastback.chat.gc-done-no-reclaim" : "ガベージコレクション完了", "fastback.chat.gc-failed" : "ガベージコレクションに失敗しました 詳細はログを参照してください", "fastback.chat.commit-failed" : "バックアップに失敗しました。 詳細はログを参照してください", "fastback.chat.commit-start" : "バックアップのスナップショットを作成しています %s", "fastback.chat.commit-complete" : "コミット完了", "fastback.chat.info-autoback-action" : "自動バックアップアクション: %s", "fastback.chat.info-autoback-wait" : "自動バックアップ実行間隔: %s分", "fastback.chat.info-backup-size" : "ローカルバックアップサイズ: %s", "fastback.chat.info-fastback-version" : "FastBack バージョン: %s", "fastback.chat.info-header" : "\nFastBack 詳細\n-------------", "fastback.chat.info-local-disabled" : "ローカルバックアップ: 無効", "fastback.chat.info-local-enabled" : "ローカルバックアップ: 有効", "fastback.chat.info-remote-url" : "リモート URL: %s", "fastback.chat.info-shutdown-action" : "シャットダウンアクション: %s", "fastback.chat.info-uuid" : "バックアップ UUID: %s", "fastback.chat.info-world-size" : "ワールドサイズ: %s", "fastback.chat.internal-error" : "予期せぬバックアップエラーが発生しました。詳細はログを参照してください", "fastback.chat.invalid-input" : "無効な入力: %s", "fastback.chat.list-local-snapshots-header" : "ローカルスナップショット:", "fastback.chat.lockfile-exists" : "バックアップのロックファイルが存在します %s", "fastback.chat.lockfile-cleanup-enabled" : "%s, ロックファイルのクリーンアップを試みています...", "fastback.chat.missing-argument" : "引数が不足しています: %s", "fastback.chat.no-change" : "変更なし", "fastback.chat.enabled" : "このワールドでは、バックアップは既に有効になっています", "fastback.chat.native-disabled" : "Native Git is disabled. FastBackはバックアップにJGitを使用します", "fastback.chat.native-info" : "git: %s\ngit-lfs: %s", "fastback.chat.native-git-not-installed" : "gitが見つかりませんでした PATH:\n%s\n\ngitをインストールするまでバックアップを実行できません\n詳しくはこちらをご覧ください https://pcal43.github.io/fastback/native-git.html", "fastback.chat.native-lfs-not-installed" : "git-lfsが見つかりませんでした PATH:\n%s\n\ngit-lfsをインストールするまでバックアップを実行できません\n詳しくはこちらをご覧ください https://pcal43.github.io/fastback/native-git.html", "fastback.chat.not-enabled" : "このワールドでバックアップは有効になっていません。 '/backup init' を実行してください", "fastback.chat.ok" : "ok", "fastback.chat.prune-done" : "%s 件のスナップショットから不要なデータが削除されました", "fastback.chat.prune-no-default" : "デフォルトのプルーニングポリシーが設定されていません。 /backup set retention-policy を実行してください", "fastback.chat.prune-suggest-gc" : "/backup gc を実行してディスク領域を確保します", "fastback.chat.push-failed" : "ローカルバックアップは成功したが、リモートバックアップに失敗しました。 詳細はログを参照してください", "fastback.chat.push-started" : "%s にバックアップをアップロードしています...", "fastback.chat.push-uuid-mismatch" : "リモート %s は別のワールドのバックアップターゲットです。このワールドのバックアップ用に新しいリモートを設定してください", "fastback.chat.push-done" : "%s にバックアップをアップロードしました。", "fastback.chat.push-done-elapsed" : "%s にバックアップをアップロードしました。 経過時間: %s", "fastback.chat.remote-delete-done" : "%s リモートスナップショットを削除しました", "fastback.chat.remote-enabled" : "リモートバックアップ:\n%s\nを有効にしました", "fastback.chat.remote-how-to-enable-no-url" : "'/backup set remote-url ' を実行してリモートバックアップを有効にします", "fastback.chat.remote-list-done" : "%s に %d 件のスナップショットが見つかりました", "fastback.chat.remote-no-url" : "リモートURLが設定されていません\n'/backup set remote-url ' を実行してください", "fastback.chat.remote-set" : "リモートバックアップURLを:\n%s\nに設定しました", "fastback.chat.remote-retention-policy-none" : "リモートスナップショットリテンションポリシーが設定されていません", "fastback.chat.remote-retention-policy-not-set": "リモートリテンションポリシーが設定されていません /backup set remote-retention-policy を実行してください", "fastback.chat.remote-retention-policy-set" : "リモートスナップショットリテンションポリシーを:%s\nとして設定しました", "fastback.chat.restore-done" : "スナップショット\n%s\nを復元しました", "fastback.chat.restore-nosuch" : "スナップショット %s がありません", "fastback.chat.retention-policy-none" : "スナップショットリテンションポリシーが設定されていません", "fastback.chat.retention-policy-not-set" : "リテンションポリシーが設定されていません /backup set retention-policy を実行してください", "fastback.chat.retention-policy-set" : "スナップショットリテンションポリシーを:%s\nとして設定しました", "fastback.chat.thread-busy" : "別のバックアップタスクが実行中です 終了するまで待ってから、もう一度お試しください", "fastback.chat.thread-waiting" : "現在のバックアップタスクが完了するのを待っています...", "fastback.chat.world-save" : "バックアップの前にワールドを保存しています...", "fastback.hud.local-saving" : "ローカルバックアップを保存しています...", "fastback.hud.prune-started" : "不要なデータを削除しています...", "fastback.message.backing-up" : "バックアップ中...", "fastback.broadcast.message" : "サーバーがバックアップを開始しています", "fastback.retain.all.description" : "すべてのスナップショットを保持し、決して削除しない", "fastback.retain.fixed.description" : "修正: 最新のスナップショットを%s件のみ保持", "fastback.retain.daily.description" : "Daily: 各日の最新のスナップショットを保持し、さらに過去%s日間のすべてのスナップショットを保持する", "fastback.retain.gfs.description" : "GFS: 今日のすべてのバックアップを保持 + 過去1週間の最新の1日ごとのバックアップ + 過去1か月の最新の週ごとのバックアップ + 各月の最新のバックアップを保持", "fastback.values.disabled" : "無効", "fastback.values.enabled" : "有効", "fastback.values.none" : "なし", "fastback.values.not-installed" : "インストールされていません" } ================================================ FILE: common/src/main/resources/assets/fastback/lang/ru_ru.json ================================================ { "fastback.help.command.create-file-remote" : "Создать шаблон для внешней резервной копии в файловой системе.", "fastback.help.command.delete" : "Удалить отдельную точку восстановления.", "fastback.help.command.disable" : "Отключить резервирование этого мира.", "fastback.help.command.enable" : "Включить локальное резервирование этого мира.", "fastback.help.command.full" : "Запустить полное резервирование немедленно.", "fastback.help.command.gc" : "Выполнить очистку мусора для освобождения дискового пространства.", "fastback.help.command.help" : "Получить справку по доступным командам.", "fastback.help.command.info" : "Показать текущий статус настроек резервирования.", "fastback.help.command.list" : "Показать список точек восстановления этого мира.", "fastback.help.command.local" : "Запустить локальное резервирование немедленно.", "fastback.help.command.prune" : "Удалить старые точки восстановления в соответствии со стратегией сохранения.", "fastback.help.command.remote-delete" : "Удалить внешнюю точку восстановления.", "fastback.help.command.remote-list" : "Показать список внешних точек восстановления этого мира.", "fastback.help.command.remote-prune" : "Удалить старые точки восстановления во внешней резервной копии в соответствии со стратегией сохранения.", "fastback.help.command.remote-restore" : "Загрузить внешнюю точку восстановления.", "fastback.help.command.restore" : "Загрузить точку восстановления.", "fastback.help.command.set-autoback-action" : "Настроить автоматическое резервирование.", "fastback.help.command.set-autoback-wait" : "Установить минимальный интервал (в минутах) для автоматического резервирования.", "fastback.help.command.set-remote" : "Задать адрес для внешнего резервирования.", "fastback.help.command.set-remote-retention" : "Установить стратегию сохранения внешних точек восстановления.", "fastback.help.command.set-retention" : "Установить стратегию сохранения точек восстановления.", "fastback.help.command.set-shutdown-action" : "Настроить резервирование при закрытии мира.", "fastback.help.subcommands" : "Список доступных аргументов:\n%s\nДля получения подробного описания аргумента используйте\n/backup help [аргумент]\nили перейдите на https://pcal43.github.io/fastback", "fastback.chat.backup-complete" : "Резервирование завершено.", "fastback.chat.create-file-remote-created" : "Git-репозиторий создан в %s\nВнешнее резервирование будет вестись в\n%s", "fastback.chat.create-file-remote-dir-exists" : "Директория уже существует:\n%s", "fastback.chat.delete-done" : "Удалена точка восстановления %s", "fastback.chat.disable-already-disabled" : "Резервирование уже отключено.", "fastback.chat.disable-done" : "Резервирование отключено.", "fastback.chat.enable-done" : "Автоматическое создание точек восстановления при закрытии мира включено.", "fastback.chat.gc-done" : "Очистка мусора завершена.", "fastback.chat.info-autoback-action" : "Автоматическое резервирование: %s", "fastback.chat.info-autoback-wait" : "Интервал авторезервирования: %s минут", "fastback.chat.info-backup-size" : "Размер локальной резервной копии: %s", "fastback.chat.info-fastback-version" : "Версия FastBack: %s", "fastback.chat.info-local-disabled" : "Локальное резервирование: отключено", "fastback.chat.info-local-enabled" : "Локальное резервирование: включено", "fastback.chat.info-remote-url" : "Внешний адрес: %s", "fastback.chat.info-shutdown-action" : "Резервирование при закрыти мира: %s", "fastback.chat.info-uuid" : "UUID резервной копии: %s", "fastback.chat.info-world-size" : "Размер мира: %s", "fastback.chat.internal-error" : "В процессе создания точки восстановления возникла непредвиденная ошибка. Проверьте журнал для подробной информации.", "fastback.chat.invalid-input" : "Некорректный ввод: %s", "fastback.chat.list-local-snapshots-header" : "Локальные точки восстановления:", "fastback.chat.not-enabled" : "Резервирование этого мира отключено. Используйте \"/backup init\" для включения.", "fastback.chat.prune-done" : "Удалено точек восстановления: %s.", "fastback.chat.prune-no-default" : "Не задана стратегия сохранения точек восстановления. Используйте /backup set retention-policy для выбора стратегии.", "fastback.chat.prune-suggest-gc" : "Используйте /backup gc для освобождения дискового пространства.", "fastback.chat.push-failed" : "Локальное резервирование завершено, однако в процессе сохранения данных удалённо возникла ошибка. Проверьте журнал для подробностей.", "fastback.chat.push-started" : "Запуск внешнего резервирования...", "fastback.chat.push-uuid-mismatch" : "Адрес %s уже назначен для другого мира.\n Пожалуйста, настройте новое расположение для резервирования.", "fastback.chat.remote-delete-done" : "Удалена внешняя точка восстановления %s", "fastback.chat.remote-enabled" : "Включено внешнее резервирование в:\n%s", "fastback.chat.remote-how-to-enable-no-url" : "Используйте \"/backup set remote-url <адрес>\" для включения внешнего резервирования.", "fastback.chat.remote-list-done" : "Найдено %d точек восстановления в %s", "fastback.chat.remote-no-url" : "Адрес для внешнего резервирования не задан.\n Используйте \"/backup set remote-url <адрес>\".", "fastback.chat.remote-set" : "Адрес внешнего резервирования изменён на\n%s", "fastback.chat.remote-retention-policy-none" : "Стратегия сохранения внешних точек восстановления не задана.", "fastback.chat.remote-retention-policy-not-set": "Стратегия сохранения внешних данных не задана. Используйте \"/backup set remote-retention-policy\"", "fastback.chat.remote-retention-policy-set" : "Стратегия сохранения внешних точек восстановления изменена:", "fastback.chat.restore-done" : "Точка восстановления сохранена как мир по адресу\n%s", "fastback.chat.restore-nosuch" : "Такой точки восстановления не существует: %s", "fastback.chat.retention-policy-none" : "Стратегия сохранения точек восстановления не задана.", "fastback.chat.retention-policy-not-set" : "Стратегия сохранения данных не задана. Run /backup set retention-policy", "fastback.chat.retention-policy-set" : "Стратегия сохранения точек восстановления изменена:", "fastback.chat.thread-busy" : "В настоящее время выполняется другая задача, связанная с резервированием. Пожалуйста, дождитесь её окончания и попробуйте снова.", "fastback.chat.thread-waiting" : "Ожидание завершения активных задач резервирования...", "fastback.hud.local-saving" : "Сохранение локальной точки восстановления.", "fastback.hud.prune-started" : "Удаление...", "fastback.retain.all.description" : "Хранить все точки восстановления, без очистки.", "fastback.retain.fixed.description" : "Количество: хранить только %s последних точек восстановления.", "fastback.retain.daily.description" : "Подневное: по прошествии %s дней удалять все точки восстановления, кроме последних за сутки.", "modmenu.summaryTranslation.fastback" : "Резервирование - быстрое, дополняемое, на основе Git.", "modmenu.descriptionTranslation.fastback" : "Создавайте резервные копии вашего мира по методу дополняемых точек восстановления. При сохранении точки резервируются только те части мира, которые были изменены с момента создания предыдущей копии.\nВозможности:\n • Резервирование только изменённых файлов\n • Резервные копии создаются быстрее и компактнее zip\n • Локальное резервирование\n • Удалённое резервирование на сетевой диск или Git-сервер\n • Простая загрузка точек восстановления\n • Управление посредством команд, и другие функции" } ================================================ FILE: common/src/main/resources/assets/fastback/lang/zh_cn.json ================================================ { "fastback.help.command.create-file-remote" : "在文件系统上创建远程备份目标。", "fastback.help.command.delete" : "删除单个快照。", "fastback.help.command.disable" : "停用此世界的备份。", "fastback.help.command.enable" : "启用此世界的本地备份。", "fastback.help.command.full" : "立即执行本地与远程备份。", "fastback.help.command.gc" : "运行垃圾回收以释放磁盘空间。", "fastback.help.command.help" : "查看命令帮助。", "fastback.help.command.info" : "查看当前备份状态与设置。", "fastback.help.command.init" : "初始化当前世界的 FastBack。请先运行此命令。", "fastback.help.command.list" : "列出此世界的备份快照。", "fastback.help.command.local" : "立即执行本地备份。", "fastback.help.command.prune" : "根据保留策略删除旧快照。", "fastback.help.command.push" : "将快照推送至远程。", "fastback.help.command.remote-delete" : "删除远程快照。", "fastback.help.command.remote-list" : "列出远程快照。", "fastback.help.command.remote-prune" : "根据远程保留策略删除远程旧快照。", "fastback.help.command.remote-restore" : "从远程恢复快照。", "fastback.help.command.restore" : "恢复备份快照。", "fastback.help.command.set" : "修改配置设置。", "fastback.help.command.set-autoback-action" : "设置自动备份时执行的操作。", "fastback.help.command.set-autoback-wait" : "设置自动备份的最小等待时间(分钟)。", "fastback.help.command.set-remote" : "设置远程备份的 URL。", "fastback.help.command.set-remote-retention" : "设置远程备份的快照保留策略。", "fastback.help.command.set-retention" : "设置快照保留策略。", "fastback.help.command.set-shutdown-action" : "设置关闭服务器时执行的操作。", "fastback.help.subcommands" : "可用子命令:\n%s\n查看子命令详细帮助,请运行:\n/backup help [子命令]\n或访问:https://pcal43.github.io/fastback", "fastback.help.suggest-init" : "\n请先输入“/backup init”以开始使用。", "fastback.help.backup-start" : "正在备份 %s", "fastback.chat.backup-complete" : "备份完成。", "fastback.chat.backup-complete-elapsed" : "备份完成,耗时:%s", "fastback.chat.create-file-remote-created" : "已在 %s 创建 Git 仓库\n远程备份已启用至:\n%s", "fastback.chat.create-file-remote-dir-exists" : "目录已存在:\n%s", "fastback.chat.delete-start" : "正在删除快照 %s %s", "fastback.chat.delete-done" : "已删除快照 %s", "fastback.chat.disable-already-disabled" : "备份已是禁用状态。", "fastback.chat.gc-done" : "垃圾回收完成,已回收 %s。", "fastback.chat.gc-done-no-reclaim" : "垃圾回收完成。", "fastback.chat.gc-failed" : "垃圾回收失败,详情请查看日志。", "fastback.chat.commit-failed" : "备份失败,详情请查看日志。", "fastback.chat.commit-start" : "正在创建备份快照 %s", "fastback.chat.commit-complete" : "提交完成。", "fastback.chat.info-autoback-action" : "自动备份操作:%s", "fastback.chat.info-autoback-wait" : "自动备份间隔:%s 分钟", "fastback.chat.info-backup-size" : "本地备份大小:%s", "fastback.chat.info-fastback-version" : "FastBack 版本:%s", "fastback.chat.info-header" : "\nFastBack 信息\n-------------", "fastback.chat.info-local-disabled" : "本地备份:已禁用", "fastback.chat.info-local-enabled" : "本地备份:已启用", "fastback.chat.info-remote-url" : "远程 URL:%s", "fastback.chat.info-shutdown-action" : "关闭操作:%s", "fastback.chat.info-uuid" : "备份 UUID:%s", "fastback.chat.info-world-size" : "世界大小:%s", "fastback.chat.internal-error" : "备份时发生意外错误,详情请查看日志。", "fastback.chat.invalid-input" : "输入无效:%s", "fastback.chat.list-local-snapshots-header" : "本地快照:", "fastback.chat.lockfile-exists" : "备份锁文件已存在。%s", "fastback.chat.lockfile-cleanup-enabled" : "%s,正在尝试清理锁文件…", "fastback.chat.missing-argument" : "缺少参数:%s", "fastback.chat.no-change" : "未更改。", "fastback.chat.enabled" : "此世界已启用备份。", "fastback.chat.native-disabled" : "已禁用原生 Git,FastBack 将使用 JGit 进行备份。", "fastback.chat.native-info" : "git:%s\ngit-lfs:%s", "fastback.chat.native-git-not-installed" : "在 PATH 中未找到 git:\n%s\n\n请安装 git 后再执行备份。\n详情请见:https://pcal43.github.io/fastback/native-git.html", "fastback.chat.native-lfs-not-installed" : "在 PATH 中未找到 git-lfs:\n%s\n\n请安装 git-lfs 后再执行备份。\n详情请见:https://pcal43.github.io/fastback/native-git.html", "fastback.chat.not-enabled" : "此世界未启用备份,请先运行“/backup init”。", "fastback.chat.ok" : "确定", "fastback.chat.prune-done" : "已清理 %s 个快照。", "fastback.chat.prune-no-default" : "未配置默认清理策略,请运行 /backup set retention-policy", "fastback.chat.prune-suggest-gc" : "可运行 /backup gc 以释放磁盘空间。", "fastback.chat.push-failed" : "本地备份成功,但远程备份失败,详情请查看日志。", "fastback.chat.push-started" : "正在上传备份至 %s…", "fastback.chat.push-uuid-mismatch" : "位于 %s 的远程目标属于其他世界。\n请为本世界配置新的远程目标。", "fastback.chat.push-done" : "备份已上传至 %s", "fastback.chat.push-done-elapsed" : "备份已上传至 %s,耗时:%s", "fastback.chat.remote-delete-done" : "已删除远程快照 %s", "fastback.chat.remote-enabled" : "已启用远程备份至:\n%s", "fastback.chat.remote-how-to-enable-no-url" : "请运行“/backup set remote-url <远程URL>”以启用远程备份。", "fastback.chat.remote-list-done" : "在 %s 找到 %d 个快照", "fastback.chat.remote-no-url" : "未设置远程 URL。\n请运行“/backup set remote-url <远程URL>”", "fastback.chat.remote-set" : "远程备份 URL 已设置为:\n%s", "fastback.chat.remote-retention-policy-none" : "未设置远程快照保留策略。", "fastback.chat.remote-retention-policy-not-set": "未设置远程保留策略,请运行 /backup set remote-retention-policy", "fastback.chat.remote-retention-policy-set" : "远程快照保留策略已设置为:", "fastback.chat.restore-done" : "快照已恢复至\n%s", "fastback.chat.restore-nosuch" : "快照 %s 不存在", "fastback.chat.retention-policy-none" : "未设置快照保留策略。", "fastback.chat.retention-policy-not-set" : "未设置保留策略,请运行 /backup set retention-policy", "fastback.chat.retention-policy-set" : "快照保留策略已设置为:", "fastback.chat.thread-busy" : "当前有备份任务正在进行,请等待完成后重试。", "fastback.chat.thread-waiting" : "正在等待当前备份任务完成…", "fastback.chat.world-save" : "正在备份前保存世界…", "fastback.hud.local-saving" : "正在保存本地备份…", "fastback.hud.prune-started" : "正在清理…", "fastback.message.backing-up" : "正在备份…", "fastback.broadcast.message" : "服务器正在开始备份。", "fastback.retain.all.description" : "保留全部快照,永不清理。", "fastback.retain.fixed.description" : "固定数量:仅保留最近的 %s 个快照。", "fastback.retain.daily.description" : "每日:保留每天的最新快照,以及最近 %s 天的所有快照。", "fastback.retain.gfs.description" : "GFS:保留今日所有备份 + 最近一周的每日最新备份 + 最近一月的每周最新备份 + 每月最新备份。", "fastback.values.disabled" : "已禁用", "fastback.values.enabled" : "已启用", "fastback.values.none" : "无", "fastback.values.not-installed" : "未安装" } ================================================ FILE: common/src/main/resources/fastback.mixins.json ================================================ { "required": true, "minVersion": "0.8", "package": "net.pcal.fastback.common.mixins", "compatibilityLevel": "JAVA_25", "mixins": [ "FileFixerUpperMixin", "MinecraftServerMixin", "ServerAccessors", "SessionAccessors" ], "client": [ "MessageScreenMixin", "ScreenAccessors" ], "injectors": { "defaultRequire": 1 } } ================================================ FILE: common/src/main/resources/world/gitattributes-jgit ================================================ # # DO NOT EDIT. CHANGES WILL BE OVERWRITTEN. # # This file is automatically updated by FastBack. If you need to customize # these rules, please read this: # # https://pcal43.github.io/fastback/advanced.html#disabling-file-updates # # jgit configuration # treat everything as binaries * -diff -merge -text -delta # ...with a few exceptions (that are unlikely to matter much, tbh). *.json diff merge text delta *.txt diff merge text delta *.properties diff merge text delta *.toml diff merge text delta *.yaml diff merge text delta ================================================ FILE: common/src/main/resources/world/gitattributes-native ================================================ # # DO NOT EDIT. CHANGES WILL BE OVERWRITTEN. # # This file is automatically updated by FastBack. If you need to customize # these rules, please read this: # # https://pcal43.github.io/fastback/advanced.html#disabling-file-updates # # native git configuration *.dat filter=lfs diff=lfs merge=lfs -text *.dat_old filter=lfs diff=lfs merge=lfs -text *.jar filter=lfs diff=lfs merge=lfs -text *.jar.disabled filter=lfs diff=lfs merge=lfs -text *.mca filter=lfs diff=lfs merge=lfs -text *.xz filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text ================================================ FILE: common/src/main/resources/world/gitignore ================================================ # # DO NOT EDIT. CHANGES WILL BE OVERWRITTEN. # # This file is automatically updated by FastBack. If you need to customize # these rules, please read this: # # https://pcal43.github.io/fastback/advanced.html#disabling-file-updates # session.lock .DS_Store ================================================ FILE: common/src/test/java/net/pcal/fastback/common/repo/V1SnapshotIdTest.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ListMultimap; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec; import net.pcal.fastback.common.repo.WorldIdUtils.WorldIdImpl; import org.apache.logging.log4j.LogManager; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.UUID; import static net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec.V1; import static org.junit.jupiter.api.Assertions.assertEquals; /** * @author pcal * @since 0.4.0 */ public class V1SnapshotIdTest { @BeforeAll public static void setup() { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("mocklogger"))); } @Test public void testParseBranch() throws ParseException { final String uuid = UUID.randomUUID().toString(); final String date = "2010-05-08_01-02-03"; final String branchName = "snapshots/" + uuid + "/" + date; final SnapshotId sid = V1.fromBranch(branchName); assertEquals(date, sid.getShortName()); assertEquals(branchName, sid.getBranchName()); assertEquals(uuid, sid.getWorldId().toString()); final Date parsedDate = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").parse(date); assertEquals(parsedDate, sid.getDate()); } @Test public void testSorting() throws ParseException { final SnapshotId s0 = V1.fromBranch("snapshots/" + UUID.randomUUID() + "/1977-09-24_01-02-03"); final SnapshotId s1 = V1.fromBranch("snapshots/" + UUID.randomUUID() + "/2010-05-08_01-02-03"); final SnapshotId s2 = V1.fromBranch("snapshots/" + UUID.randomUUID() + "/2013-10-02_01-02-03"); List list = new ArrayList<>(List.of(s1, s2, s0)); Collections.sort(list); assertEquals(List.of(s0, s1, s2), list); } @Test public void testSortWorldSnapshots() throws ParseException { final WorldId uuid0 = new WorldIdImpl(UUID.randomUUID().toString()); final WorldId uuid1 = new WorldIdImpl(UUID.randomUUID().toString()); final ListMultimap sids = ArrayListMultimap.create(); final SnapshotId s0 = V1.fromBranch("snapshots/" + uuid0 + "/1977-09-24_01-02-03"); final SnapshotId s1 = V1.fromBranch("snapshots/" + uuid0 + "/2010-05-08_01-02-03"); final SnapshotId s2 = V1.fromBranch("snapshots/" + uuid0 + "/2013-10-02_01-02-03"); sids.put(uuid0, s0); sids.put(uuid0, s1); sids.put(uuid0, s2); final SnapshotId s3 = V1.fromBranch("snapshots/" + uuid1 + "/1977-09-24_01-02-03"); final SnapshotId s4 = V1.fromBranch("snapshots/" + uuid1 + "/2010-05-08_01-02-03"); final SnapshotId s5 = V1.fromBranch("snapshots/" + uuid1 + "/2013-10-02_01-02-03"); sids.put(uuid1, s3); sids.put(uuid1, s4); sids.put(uuid1, s5); assertEquals(List.of(s0, s1, s2), sorted(sids.get(uuid0))); assertEquals(List.of(s3, s4, s5), sorted(sids.get(uuid1))); } private static List sorted(Collection sids) { List out = new ArrayList<>(sids); Collections.sort(out); return out; } // so other tests can get at it public static SnapshotId v1sid(WorldId wid, Date date) throws ParseException { return V1.create(wid, SnapshotIdCodec.DATE_FORMAT.format(date)); } public static SnapshotId v1sid(String wid, Date date) throws ParseException { return V1.create(createWorldId(wid), SnapshotIdCodec.DATE_FORMAT.format(date)); } public static WorldId createWorldId(String wid) { return new WorldIdImpl(wid); } } ================================================ FILE: common/src/test/java/net/pcal/fastback/common/repo/V2SnapshotIdTest.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.repo; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ListMultimap; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import org.apache.logging.log4j.LogManager; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; import static net.pcal.fastback.common.repo.SnapshotIdUtils.SnapshotIdCodec.V2; import static net.pcal.fastback.common.repo.V1SnapshotIdTest.createWorldId; import static net.pcal.fastback.common.repo.WorldIdUtils.generateRandomWorldId; import static org.junit.jupiter.api.Assertions.assertEquals; /** * @author pcal * @since 0.15.0 */ public class V2SnapshotIdTest { @BeforeAll public static void setup() { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("mocklogger"))); } @Test public void testWorldIdGeneration() { for (int i = 0; i < 10000; i++) generateRandomWorldId(4); } @Test public void testParseBranch() throws ParseException { final String wid = generateRandomWorldId(4); final String date = "2010-05-08_01-02-03"; final String branchName = wid + "/" + date; final SnapshotId sid = V2.fromBranch(branchName); assertEquals(date, sid.getShortName()); assertEquals(branchName, sid.getBranchName()); assertEquals(wid, sid.getWorldId().toString()); final Date parsedDate = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").parse(date); assertEquals(parsedDate, sid.getDate()); } @Test public void testSorting() throws ParseException { final SnapshotId s0 = V2.fromBranch(generateRandomWorldId(4) + "/1977-09-24_01-02-03"); final SnapshotId s1 = V2.fromBranch(generateRandomWorldId(10) + "/2010-05-08_01-02-03"); final SnapshotId s2 = V2.fromBranch(generateRandomWorldId(100) + "/2013-10-02_01-02-03"); List list = new ArrayList<>(List.of(s1, s2, s0)); Collections.sort(list); assertEquals(List.of(s0, s1, s2), list); } @Test public void testSortWorldSnapshots() throws ParseException { final WorldId wid0 = createWorldId(generateRandomWorldId(4)); final WorldId wid1 = createWorldId(generateRandomWorldId(5)); final ListMultimap sids = ArrayListMultimap.create(); final SnapshotId s0 = V2.fromBranch(wid0 + "/1977-09-24_01-02-03"); final SnapshotId s1 = V2.fromBranch(wid0 + "/2010-05-08_01-02-03"); final SnapshotId s2 = V2.fromBranch(wid0 + "/2013-10-02_01-02-03"); sids.put(wid0, s0); sids.put(wid0, s1); sids.put(wid0, s2); final SnapshotId s3 = V2.fromBranch(wid1 + "/1977-09-24_01-02-03"); final SnapshotId s4 = V2.fromBranch(wid1 + "/2010-05-08_01-02-03"); final SnapshotId s5 = V2.fromBranch(wid1 + "/2013-10-02_01-02-03"); sids.put(wid1, s3); sids.put(wid1, s4); sids.put(wid1, s5); assertEquals(List.of(s0, s1, s2), sorted(sids.get(wid0))); assertEquals(List.of(s3, s4, s5), sorted(sids.get(wid1))); } private static List sorted(Collection sids) { List out = new ArrayList<>(sids); Collections.sort(out); return out; } } ================================================ FILE: common/src/test/java/net/pcal/fastback/common/retention/DailyRetentionPolicyTest.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.repo.SnapshotId; import net.pcal.fastback.common.repo.WorldId; import org.apache.logging.log4j.LogManager; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.text.ParseException; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.UUID; import static net.pcal.fastback.common.repo.V1SnapshotIdTest.createWorldId; import static net.pcal.fastback.common.repo.V1SnapshotIdTest.v1sid; public class DailyRetentionPolicyTest { private static final long HOUR_MILLIS = 1000 * 60 * 60; private static final long DAY_MILLIS = HOUR_MILLIS * 24; @BeforeAll public static void setup() { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("mocklogger"))); } @Test public void testDailyRetention() throws ParseException { final WorldId uuid = createWorldId(UUID.randomUUID().toString()); long now = new Date().getTime(); final SnapshotId todayEvening = v1sid(uuid, new Date(now + now % DAY_MILLIS - (4 * HOUR_MILLIS))); final SnapshotId todayMorning = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS / 2))); final SnapshotId yesterdayA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 30000)); final SnapshotId yesterdayB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 20000)); final SnapshotId yesterdayC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (DAY_MILLIS) - 10000)); final SnapshotId threeDaysAgoA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 30000)); final SnapshotId threeDaysAgoB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 20000)); final SnapshotId threeDaysAgoC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (3 * DAY_MILLIS) - 10000)); final SnapshotId lastWeek = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (7 * DAY_MILLIS))); final SnapshotId lastYearA = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 30000)); final SnapshotId lastYearB = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 20000)); final SnapshotId lastYearC = v1sid(uuid, new Date(todayEvening.getDate().getTime() - (373 * DAY_MILLIS) - 10000)); final int GRACE_PERIOD = 2; TreeSet snapshots = new TreeSet<>(Set.of( todayEvening, todayMorning, yesterdayA, yesterdayB, yesterdayC, threeDaysAgoA, threeDaysAgoB, threeDaysAgoC, lastWeek, lastYearA, lastYearB, lastYearC)); RetentionPolicy policy = DailyRetentionPolicy.DailyRetentionPolicyType.INSTANCE.createPolicy( Map.of("gracePeriodDays", String.valueOf(GRACE_PERIOD))); Collection toPruneList = policy.getSnapshotsToPrune(snapshots); Assertions.assertEquals(List.of(threeDaysAgoB, threeDaysAgoA, lastYearB, lastYearA), toPruneList); } } ================================================ FILE: common/src/test/java/net/pcal/fastback/common/retention/GFSRetentionPolicyTest.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.repo.SnapshotId; import net.pcal.fastback.common.repo.V1SnapshotIdTest; import org.apache.logging.log4j.LogManager; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.text.ParseException; import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Set; import java.util.TimeZone; import java.util.TreeSet; import java.util.function.Function; public class GFSRetentionPolicyTest { @BeforeAll public static void setup() { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("mocklogger"))); } @Test public void testGFSRetention() throws ParseException { final LocalDate now = LocalDate.of(2023, 2, 23); // this is a wednesday final List expectPruned = new ArrayList<>(); Function pruned = sid -> { expectPruned.add(sid); return sid; }; TreeSet snapshots = new TreeSet<>(Set.of( sid(2023, 2, 23, 9), sid(2023, 2, 23, 8), sid(2023, 2, 23, 7), // keep everything from today sid(2023, 2, 22, 9), sid(2023, 2, 22, 8), sid(2023, 2, 22, 7), // and yesterday, too // these are on unique days in the past week, should be kept sid(2023, 2, 16, 9), sid(2023, 2, 17, 9), sid(2023, 2, 18, 9), // this one is earlier in the day on the 18th, should be pruned pruned.apply(sid(2023, 2, 18, 8)), // keep only the newest one from the previous week sid(2023, 2, 11, 9), pruned.apply(sid(2023, 2, 7, 8)), pruned.apply(sid(2023, 2, 6, 8)), // same thing the week before that sid(2023, 2, 4, 9), pruned.apply(sid(2023, 1, 31, 8)), pruned.apply(sid(2023, 1, 30, 8)), // and then we get into the previous month - pruning is more aggressive sid(2023, 1, 17), pruned.apply(sid(2023, 1, 9)), pruned.apply(sid(2023, 1, 2)), pruned.apply(sid(2023, 1, 1, 8)), // previous month, same deal... sid(2022, 12, 31), pruned.apply(sid(2022, 12, 15)), pruned.apply(sid(2022, 12, 1)), // and so on sid(2022, 11, 4), pruned.apply(sid(2022, 11, 3)), pruned.apply(sid(2022, 11, 2)) )); RetentionPolicy policy = GFSRetentionPolicy.GFSRetentionPolicyType.INSTANCE.createPolicy(Collections.emptyMap()); ((GFSRetentionPolicy) policy).nowSupplier = () -> now; Collection toPruneList = policy.getSnapshotsToPrune(snapshots); Assertions.assertEquals(expectPruned, toPruneList); } private static SnapshotId sid(int year, int month, int day) throws ParseException { return sid(year, month, day, 11); } private static SnapshotId sid(int year, int month, int day, int hour) throws ParseException { Date date = Date.from(ZonedDateTime.of(LocalDate.of(year, month, day).atTime(hour, 0), TimeZone.getDefault().toZoneId()).toInstant()); return V1SnapshotIdTest.v1sid("3552efde-b34d-11ed-afa1-0242ac120002", date); } } ================================================ FILE: common/src/test/java/net/pcal/fastback/common/retention/RetentionPolicyCodecTest.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.common.retention; import net.pcal.fastback.common.logging.Log4jLogger; import net.pcal.fastback.common.logging.SystemLogger; import net.pcal.fastback.common.logging.UserMessage; import net.pcal.fastback.common.repo.SnapshotId; import org.apache.logging.log4j.LogManager; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; public class RetentionPolicyCodecTest { @BeforeAll public static void setup() { SystemLogger.Singleton.register(new Log4jLogger(LogManager.getLogger("mocklogger"))); } @Test public void testEncodePolicy() { final String encodedPolicy = RetentionPolicyCodec.INSTANCE.encodePolicy( MockRetentionPolicyType.INSTANCE, Map.of("foo", "bar", "baz", "bop", "bad key", "whatever")); assertEquals("mock-policy baz=bop foo=bar", encodedPolicy); } @Test public void testDecodePolicy() { final RetentionPolicy policy = RetentionPolicyCodec.INSTANCE.decodePolicy( List.of(MockRetentionPolicyType.INSTANCE), "mock-policy foo=bar baz=bop random junk should be ignored" ); assertTrue(policy instanceof MockRetentionPolicy); assertEquals(((MockRetentionPolicy) policy).config, Map.of("foo", "bar", "baz", "bop")); } @Test public void testEncodeMap() { String encoded = RetentionPolicyCodec.encodeMap( Map.of("foo", "bar", "baz", "bop", "bad key", "whatever")); assertEquals("baz=bop foo=bar", encoded); } @Test public void testDecodeMap() { final String encoded = "foo=bar baz=bop random junk should be ignored"; Map decoded = RetentionPolicyCodec.decodeMap(encoded); assertEquals(Map.of("foo", "bar", "baz", "bop"), decoded); } private static class MockRetentionPolicy implements RetentionPolicy { private final Map config; public MockRetentionPolicy(Map config) { this.config = config; } @Override public UserMessage getDescription() { return UserMessage.raw("mock policy"); } @Override public Collection getSnapshotsToPrune(Set fromSnapshots) { throw new IllegalStateException(); } } private enum MockRetentionPolicyType implements RetentionPolicyType { INSTANCE; @Override public String getName() { return "mock-policy"; } @Override public List> getParameters() { throw new IllegalStateException(); } @Override public RetentionPolicy createPolicy(Map config) { return new MockRetentionPolicy(config); } @Override public UserMessage getDescription() { return UserMessage.raw("mock retention policy"); } } ; } ================================================ FILE: docs/_config.yml ================================================ title: FastBack - Minecraft World Backups logo: /fastback-icon.png remote_theme: just-the-docs/just-the-docs search_enabled: true color_scheme: dark aux_links: "Download": - "https://www.curseforge.com/minecraft/mc-mods/fastback/files" "Source": - "https://github.com/pcal43/fastback" "Issues": - "https://github.com/pcal43/fastback/issues" "Discord": - "https://discord.pcal.net" ================================================ FILE: docs/actions-list.md ================================================ Action | Use ---------------------- | --- `local` | Backs up locally only. Like `/backup local` `full` | Back up locally and upload. Like `/backup full` `full-gc` | Do a full backup followed by a `prune` and `gc` to reclaim disk space.
This might slow your game down if scheduled during autosaves. `none` | Don't do anything ================================================ FILE: docs/advanced.md ================================================ --- layout: default title: Advanced Usage nav_order: 90 --- # Advanced Usage This page assumes you already know how to use git. If you don't, you should skip it. ## Disabling file updates FastBack automatically performs updates to some key git files in your world repo. Advanced users can disable these updates by changing the repo's git configuration: | Config Key | Use | |-----------------------------------------|-----------------------------------------------------------------------------------------------| | `fastback.update-gitignore-enabled` | Defaults to `true`. Set to `false` to disable automatic updates to the root `.gitignore` | | `fastback.update-gitattributes-enabled` | Defaults to `true`. Set to `false` to disable automatic updates to the root `.gitattributes` | For example, running ``` git config fastback.update-gitignore-enabled false ``` in your world folder will disable `.gitignore` updates. Be aware that you might miss out on future optimizations or bug fixes in these files; by disabling the updates, you take full responsibility for maintaining them. As an alternative, you might want to consider adding custom `.gitignore` or `.gitattributes` files in subdirectories and letting FastBack continue to auto-update the root files. ### World UUID FastBack 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. If 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`. ## Manually Restoring a Remote Snapshot FastBack backups are just regular git repos. This means you can use the terminal and the `git` command line tool to interact with them. To restore from a remote manually using `git`: 1. Install `git`. Mac and Linux users should already have it; Windows users may need to go [here](https://git-scm.com/downloads). 2. Clone the backup repo and list snapshots ``` git clone [repo-url] cd [directory that just got created] git branch ``` This will list all of your available snapshot branches: ``` snapshots/12345678-1234-5678-1234-567812345678/2022-10-02_12_56_33 snapshots/12345678-1234-5678-1234-567812345678/2022-10-07_11_49_31 ``` To retrieve one of them, type: ``` git checkout snapshots/12345678-1234-5678-1234-567812345678/2022-10-02_12_56_33 ``` Your world save files will appear in the directory. You can then copy them into your minecraft installation. ## Notes for Dedicated Servers: By default, Fastback will broadcast a message when a backup is about to start, so players know that things might get choppy for a bit. You can configure this in `[worlddir]/.git/config`: ``` [fastback] broadcast-notice-enabled = true broadcast-notice-message = My custom message. ``` ## Debugging If things go haywire, you can run ``` /backup set force-debug-enabled true ``` to temporarily send debugging output to the minecraft logs. This can be useful in tracking down problems. ================================================ FILE: docs/commands-list.md ================================================ | Command | Use | |-----------------------------------|------------------------------------------------------------------------------------------| | `init` | Initialize fastback for the current world. Run this first. | | `help` | Get help on commands. | | `local` | Perform a local backup immediately. | | `full` | Perform a local backup followed by a remote push (if configured). | | `restore` | Restore a backup snapshot. | | `delete` | Delete an individual snapshot. | | `info` | Info about current backup state and settings. | | `list` | List backup snapshots for this world. | | `push` _NEW_! | Push a snapshot to the remote. | | `prune` | Delete old snapshots according to the retention policy. | | `gc` | Run garbage collection to free up disk space. | | `create-file-remote` | Create a remote backup target on the file system. | | `remote-delete` | Delete a remote snapshot. | | `remote-list` | List remote snapshots. | | `remote-prune` | Delete old snapshots from the remote backup according to the remote retention policy. | | `remote-restore` | Restore a remote snapshot. | | `set retention-policy` | Set retention policy for local snapshots. | | `set remote-url` | Set the url for remote backups. | | `set shutdown-action` | Set an action to perform on shutdown. | | `set autoback-action` | Set an action to perform during auto-backups. | | `set autoback-wait` | Set the minimum number of minutes to wait between auto-backups. | | `set restore-directory` | Target directory for restored snapshots. Useful for servers with limited tmp space. | | `set remote-retention-policy` | Set retention policy for remote snapshots. | | `set mods-backup-enabled` _NEW_! | Whether to also backup mod jars and config files (in `.fastback/mods-backup`) | | `set broadcast-enabled` _NEW_! | Whether to send a server-wide notice when a backup is starting. | | `set broadcast-message`_NEW_! | Customized server-wide notice message. | | `set lock-cleanup-enabled` _NEW_! | Automatic cleanup of orphaned `index.lock` files. Be careful! | | `set force-debug-enabled` _NEW_! | Enable verbose debugging output to the console. Useful if you're running into problems. | ================================================ FILE: docs/commands.md ================================================ --- layout: default title: Commands nav_order: 20 --- # Commands *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).* Type `/backup` followed by one of these subcommands: {% include_relative commands-list.md %} ================================================ FILE: docs/diskspace.md ================================================ --- layout: default title: Disk Space nav_order: 30 --- # Managing Disk Space FastBack makes it easy for you to store lots of snapshots of your world. They save quickly and don't use up too much disk space. But eventually, you'll probably want to get rid of older snapshots you don't need anymore so you can get some disk space back. ## Pruning Snapshots To do this, you can run the `prune` command to delete old snapshots that you don't need anymore. ``` /backup prune ``` By default, this will retain the last snapshot from each day, plus all snapshots from the last 3 days. All other snapshots will be removed. ## Changing How Snapshots are Retained You can change the rules for retaining snapshots by running `set retention-policy`: ``` /backup set retention-policy [policy] [arguments...] ``` Where `[policy]` is one of {% include_relative retention-list.md %} For example, to change the policy to keep the five most-recent snapshots, run: ``` /backup set retention-policy fixed 5 ``` ## Collecting Garbage The `prune` command marks the snapshots as unused but does not delete from disk. To actually delete the snapshots and reclaim the disk space they occupy, you need to ``` /backup gc ``` WARNING: This command can take a long time (5+ minutes). For large worlds (1gb+), you may be better off running `git gc` from the command line instead. ## Managing Snapshots on a Remote You can also manage snapshots on a remote backup on a similar way using the `set remote-retention-policy` and `remote-prune` commands. For example, ``` /backup set remote-retention-policy daily 7 ``` will set the retention policy for snapshots in the remote backup to keep all snapshots for the last 7 days and at most one snapshot per day before that. Then, you can prune old snapshots on the remote by running ``` /backup remote-prune ``` **Note:** it is *not* possible to perform garbage collection on the remote using minecraft commands; you have to run it directly on the server. Many git servers will do this automatically for you but it depends on which server you're using and how it's configured. ================================================ FILE: docs/faq.md ================================================ --- layout: default title: FAQ nav_order: 99 --- # FAQ ## What is an Incremental Backup? Say you're playing for a few hours in a Minecraft world that takes up 5GB but you spend the whole time just working on your base. Minecraft only changes files for the parts of the world you changed, which might only be 50 or 100MB of files. The other 4.9GB is completely the same as it was before you started playing. When you're done playing, wouldn't it be nice to back up only the parts of the world that changed, without making a whole new copy of all the stuff that didn't change? That's what FastBack does. ## How big of a world can I back up? FastBack is designed for worlds up to about 5GB. It may work ok with worlds larger than that; please give it a try and let us know! But if you're running a server with a 200GB world, you're probably better off sticking with rsync (or whatever you're using). ## I just turned it on and it's taking a while. I thought you said this thing was fast? The first time you back up, it's going to take a while to establish a 'base' snapshot. The *next* time you back up on top of that base, it will be a lot faster. ## Where are the backups stored? The backups are stored inside your world folder, in a secret directory called `.git`. You won't see any files in there that you recognize; to get your backups out, you need to use the `/backup restore` command. ## Why did my world folder get so much bigger? The first time you do a backup, all the files are backed up in the world folder under `.git`. But the next time you back up, only changed files will be backed up. See question above about incremental backups. *Technical detail: It's just a regular git repository, no shenanigans.* ## Can I back up my world to github? You can do a remote backup to any git server, including github. But because github has certain [size restrictions](https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github), we'd only recommend using it for smaller worlds (under 500MB). ## I thought git was bad for backups? Git is a popular source code management tool used by software developers. And it's *really* good at storing text files, such as program source code. But when it comes to binary files - images or, say, minecraft region files - git's text file magic doesn't work. And software developers can run into a lot of problems if they carelessly mix binary files with source code in a git repo. For this reason, most people think that "git is bad for binary files." But with careful handling, git can actually be used to store just about anything. Technical detail: FastBack disables delta compression, stores each backup snapshot in an orphan branch and aggressively prunes reflogs and tracking branches.* ================================================ FILE: docs/index.md ================================================ --- layout: default title: FastBack nav_order: 1 --- # FastBack *Fast, incremental Minecraft world backups powered by Git* Fastback is a 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. This means backups are fast. It also means you can keep snapshots of your world without using up a lot of disk space. **IMPORTANT:** Fastback requires that you have [native git and git-lfs](native-git.md) installed. ## Features * **Now with Forge support!** * Incrementally backup just the changed files * Faster, smaller backups than zipping * Back up locally * Back up remotely to any git server * Back up remotely to any network volume (no git server required) * Schedule backups to run automatically * Easily restore backup snapshots * Snapshot pruning, retention policies * Include mod jars and config files in backup snapshots * Broadcast server-wide notifications during backups * LuckPerms support * Works on clients and dedicated servers * Works on Linux, Mac and Windows * ..all with easy-to-use minecraft commands ## Road Map * Support for restoring remote snapshots * Better management of remote snapshots * UI for managing backups from the title screen * ~~Forge support (maybe)~~ ## Acknowledgements * Russian localization provided by [Felix14-v2](https://github.com/Felix14-v2). * Chinese localization provided by [buiawpkgew1](https://github.com/buiawpkgew1) and [CMJNB](https://github.com/CMJNB). * Fastback includes and was made possible by the work of committers on these projects: * [JGit](https://www.eclipse.org/jgit/) from The Eclipse Software Foundation * [sshd](https://mina.apache.org/sshd-project/) from The Apache Software Foundation * [JavaEWAH](https://github.com/lemire/javaewah) from Daniel Lemire, et al. * [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api) from lucko * [server-translations-api](https://github.com/NucleoidMC/Server-Translations) from the Fabric Community ## Legal FastBack is distributed under [GNU Public License version 2](https://github.com/pcal43/fastback/blob/main/LICENSE). You can put it in a modpack but please include attribution with a link to this page. ================================================ FILE: docs/native-git.md ================================================ --- layout: default title: Native Git Support nav_order: 95 --- # Native Git As of version `0.17.1`, Fastback **requires** that you have native `git` and `git-lfs` installed on your machine. ## Installing Native Git Installing git is highly dependent on your platform but there are tons of resources on the web describing how to do it. Here are some good places to start: * Installing [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) * Installing [git-lfs](https://github.com/git-lfs/git-lfs?tab=readme-ov-file#installing) Note that both git and git-lfs must be available on the `PATH` of the Minecraft process. ## How do I know if Native Git has been installed correctly? You can check the minecraft startup logs for lines that look like this ``` [14:39:01] [Render thread/INFO] (fastback) git is installed: git version 2.43.0 [14:39:01] [Render thread/WARN] (fastback) git-lfs is not installed. ``` You can also type `/backup info` into the chat to check. ## Older Backups Backups created prior to version 0.17.1 in non-native mode enabled will continue to function with jgit (and in fact are incompatible with native mode in some ways). ## Why is native git required now? Non-native mode relied on java-based re-implementation of git called JGit. While JGit is an impressive piece of engineering, it has proven to have some annoying differences from native git and is also much less performant. And it's just become too burdensome to support two modes, especially when one of them is unreliable. Requiring native git also ensures that backups will always be manageable with the standard git command-line tool (which is not a guarantee with jgit). ## I can't figure out how to get native git installed. What do I do? This is understandable if you're new to git and/or system administration. You can ask on the discord channel for help. But to be completely honest, if you find this process daunting, you might want to consider other backup options. ================================================ FILE: docs/permissions-list.md ================================================ * `fastback.command` * `fastback.command.create-file-remote` * `fastback.command.delete` * `fastback.command.disable` * `fastback.command.enable` * `fastback.command.full` * `fastback.command.gc` * `fastback.command.help` * `fastback.command.info` * `fastback.command.list` * `fastback.command.local` * `fastback.command.prune` * `fastback.command.remote-delete` * `fastback.command.remote-list` * `fastback.command.remote-prune` * `fastback.command.remote-restore` * `fastback.command.restore` * `fastback.command.set` ================================================ FILE: docs/permissions.md ================================================ --- layout: default title: Permissions nav_order: 80 --- # Permissions In single-player mode, `/backup` can be run without enabling cheats. On a dedicated server, `/backup` can be run only by level 4 operators. ### LuckPerms Support FastBack exposes fine-grained permissions via the [fabric-permissions-api](https://github.com/lucko/fabric-permissions-api) so that you can do access control in [LuckPerms](https://luckperms.net/). Supported Permissions: {% include_relative permissions-list.md %} ================================================ FILE: docs/remote.md ================================================ --- layout: default title: Remote Backups nav_order: 40 --- # Remote Backups An important part of any backup strategy is to keep a copy of the backup on a different computer. FastBack makes that easy. ## Setting a remote Backup Target FastBack can automatically upload copies of your backups to another git repository. We call this other repository the *remote*. ### Configuring remote backups to a git server If you have a git server already running (GitHub, for example), all you need to do is * create a repository on the server to store your world's backups * get the URL to the repository (e.g., `ssh://192.168.0.99/mygitserver/myworld`) Then, with your world running in Minecraft, type ``` /backup set remote-url ssh://192.168.0.99/mygitserver/myworld ``` ### Configuring remote backups to a file remote If you don't have a git server, no problem. You can also do remote backups to any network drive on your computer. Just type something like ``` /backup create-file-remote /path/to/network/volume/minecraft-backups/myworld ``` You can configure this to be any valid path on your file system. But it makes the most sense to do your backups to another machine on your network. If you ever need to reattach a world to an existing file remote, you can use the `set-remote` command with a `file://` url. For the example above, that would be ``` /backup set remote-url file:///path/to/network/volume/minecraft-backups/myworld ``` ## Restoring a Remote Snapshot Say the unthinkable happens: your hard drive crashes. Your Minecraft world is lost...unless you've been keeping remote backups! You can list snapshots from the remote just as you can from your local backup: ``` /backup remote-list 2022-09-24_13_23_11 2022-10-02_12_56_33 2022-10-07_11_49_31 ``` and then restore one like so: ``` /backup remote-restore 2022-10-02_12_56_33 Snapshot restored to /home/pcal/minecraft/saves/MyWorld-2022-10-02_12_56_33 ``` Just as with local snapshots, restoring a remote snapshots creates a *new* world; existing worlds are never changed. The path to the restored world will be displayed after you run the command. ================================================ FILE: docs/retention-list.md ================================================ Action | Use ---------------------- | --- `daily` | Daily: Keep the last snapshot from each day, plus all snapshots from the last `n` days `fixed` | Fixed: Keep only the `n` most-recent snapshots. `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 `all` | Retain all snapshots; never prune ================================================ FILE: docs/scheduling.md ================================================ --- layout: default title: Scheduling nav_order: 20 --- # Scheduling You can schedule backups to run automatically when the world shuts down or auto-saves. ## Backing up on shutdown Backups can be run whenever the world shuts down (i.e., when exiting a single-player world or shutting down a dedicated server). To do this, ``` /backup set shutdown-action [action] ``` Where `[action]` is one of {% include_relative actions-list.md %} ## Backing up while the game is running You can also set backups to run while the game is playing, immediately after the regular auto-saves that Minecraft performs every 5 minutes. To do this, ``` /backup set autoback-action [action] ``` Where `[action]` is one of the actions listed above. If you don't want `autoback` backups to run every 5 minutes, you can schedule them to run less-frequently: ``` /backup set autoback-wait [minutes] ``` This sets the minimum wait time between auto-backups. So, for example, setting `[minutes]` to 120 will cause backups to run *roughly* every two hours; the exact timing will depend on when the next autosave runs. ================================================ FILE: docs/usage.md ================================================ --- layout: default title: Using FastBack nav_order: 10 --- # Using FastBack FastBack adds a custom `/backup` command that is used for all backup operations. To get detailed help about using it, just type. **IMPORTANT:** Fastback requires that you have [native git and git-lfs](native-git.md) installed. ``` /backup help ``` This page explains how to do most common tasks. ## Enabling Backups on a world To enable backups on your world, just run ``` /backup init ``` you can then type ``` /backup local ``` to do backup right away. You can then run other commands to set up automatic backups, remote backups pruning policies and more. Read on. ## Listing available backup snapshots Every time FastBack runs, it creates a *snapshot* of your world. Snapshots are identified by the time they were created. To see all snapshots in your backup of the current world, run ``` /backup list Available snapshots: 2022-10-07_10-11-12 2022-10-02_23-43-01 2022-09-24_11-32-59 2022-05-08_08-56-33 ``` ## Restoring a backup snapshot You can restore your world from any snapshot in the backup by running ``` /backup restore 2022-10-02_10-11-12 Restoring 2022-10-02_10-11-12 to /home/pcal/minecraft/saves/MyWorld-2022-10-02_10-11-12 ``` This will create a copy of your world as it was when that snapshot was made. Note 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. To look at the restored snapshot, quit the current world and open the restored snapshot world. (In server mode, you'll have to manually copy the restored files from the location displayed at the end of the command). ================================================ FILE: etc/blurb.md ================================================ # Fast Backups *Fast, incremental Minecraft world backups powered by Git* ***NOTE: FastBack is still in alpha. See [the docs]( https://pcal43.github.io/fastback/#current-limitations) for a list of known issues and limitations.*** Fast 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. This means backups are fast. It also means you can keep snapshots of your world without using up a lot of disk space. ## Features * Incrementally backup just the changed files * Faster, smaller backups than zipping * Back up locally * Back up remotely to any git server * Back up remotely to any network volume (no git server required) * Schedule backups to run automatically * Easily restore backup snapshots * Snapshot pruning, retention policies * LuckPerms support * Works on clients and dedicated servers * Works on Linux, Mac and Windows * ..all with easy-to-use minecraft commands ## Questions? * [Documentation](https://pcal43.github.io/fastback) * [Discord](https://discord.gg/jUP5nSPrjx) ![](https://pcal43.github.io/fastback/savescreen_animation.gif) ================================================ FILE: etc/docgen.sh ================================================ #!/bin/sh # # Always run this in the root of the repo # cd $(git rev-parse --show-toplevel) # # generate commands-list.md # echo ''' | Command | Use | | ---------------------- | --- |''' > docs/commands-list.md cat ./src/main/resources/assets/fastback/lang/en_us.json | \ jq -r 'to_entries[] |select(.key|match("fastback.help.command.*")) | ([ (["| `", (.key|split(".")[3]), "`"] | join("")), .value, ""] | join("|")) ' \ >> docs/commands-list.md # # generate permissions-list.md # echo ''' * `fastback.command`''' > docs/permissions-list.md cat ./src/main/resources/assets/fastback/lang/en_us.json | \ jq -r 'to_entries[] |select(.key|match("fastback.help.command.*")) | (["* `fastback.command.", (.key|split(".")[3]), "`"]|join(""))' \ >> docs/permissions-list.md ================================================ FILE: fabric/build.gradle ================================================ plugins { id 'net.fabricmc.fabric-loom' id 'com.modrinth.minotaur' id 'net.darkhax.curseforgegradle' } base { archivesName = "${rootProject.ext.archivesBaseNameFabric}-${project.mod_version}" } repositories { mavenCentral() maven { url = 'https://maven.fabricmc.net/' } maven { url = 'https://repo.spongepowered.org/maven/' } maven { url = 'https://maven.nucleoid.xyz/' } } dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" implementation "net.fabricmc:fabric-loader:${fabric_loader_version}" implementation "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}" implementation("me.lucko:fabric-permissions-api:${fabric_permissions_version}") { transitive = false } include("me.lucko:fabric-permissions-api:${fabric_permissions_version}") { transitive = false } implementation("xyz.nucleoid:server-translations-api:${server_translations_version}") include("xyz.nucleoid:server-translations-api:${server_translations_version}") // jgit and dependencies implementation("org.eclipse.jgit:org.eclipse.jgit:${jgit_version}") { transitive = false } include("org.eclipse.jgit:org.eclipse.jgit:${jgit_version}") { transitive = false } implementation("com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}") { transitive = false } include("com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}") { transitive = false } implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}") { transitive = false } include("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}") { transitive = false } implementation("org.apache.sshd:sshd-core:${apache_sshd_version}") { transitive = false } include("org.apache.sshd:sshd-core:${apache_sshd_version}") { transitive = false } implementation("org.apache.sshd:sshd-common:${apache_sshd_version}") { transitive = false } include("org.apache.sshd:sshd-common:${apache_sshd_version}") { transitive = false } implementation("net.i2p.crypto:eddsa:${eddsa_version}") { transitive = false } include("net.i2p.crypto:eddsa:${eddsa_version}") { transitive = false } compileOnly project(':common') } processResources { inputs.property "mod_version", mod_version inputs.property "minecraft_version", minecraft_version inputs.property "java_version", java_version inputs.property "fabric_loader_version", fabric_loader_version filesMatching("fabric.mod.json") { expand "mod_version": mod_version, "minecraft_version": minecraft_version, "java_version": java_version, "fabric_loader_version": fabric_loader_version } } sourceSets { main { java { srcDir project(":common").file("src/main/java") } resources { srcDir project(":common").file("src/main/resources") } } } loom { runs { client { client() setConfigName("Fabric Client") ideConfigGenerated(true) runDir("run") } server { server() setConfigName("Fabric Server") ideConfigGenerated(true) runDir("run") } } mods { "fastback" { sourceSet sourceSets.main } } } jar { from("LICENSE") { rename { "${it}_${archives_base_name}" } } } // Publishing configuration modrinth { token = System.getenv("MODRINTH_TOKEN") projectId = modrinth_projectId versionNumber = mod_version versionName = "${mod_version} (fabric)" versionType = "release" uploadFile = jar changelog = "

${github_projectUrl}/releases/tag/${mod_version}

" gameVersions = [minecraft_version] loaders = ["fabric"] dependencies { required.project "fabric-api" } } import net.darkhax.curseforgegradle.TaskPublishCurseForge tasks.register('publishCurseForge', TaskPublishCurseForge) { apiToken = System.getenv("CURSEFORGE_TOKEN") ?: 'CURSEFORGE_TOKEN NOT_SET' def mainFile = upload(curseforge_projectId, jar) mainFile.releaseType = "release" mainFile.changelog = "${github_projectUrl}/releases/tag/${mod_version}" mainFile.changelogType = "markdown" mainFile.addGameVersion(minecraft_version) mainFile.addModLoader("Fabric") dependsOn(jar) } ================================================ FILE: fabric/src/main/java/net/pcal/fastback/fabric/FabricClientInitializer.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.fabric; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.minecraft.resources.Identifier; import net.pcal.fastback.common.mod.ClientHelper; import net.pcal.fastback.common.mod.Mod; /** * Initializer that runs on the client (both integrated and dedicated-server-from-client). * * @author pcal * @since 0.0.1 */ public class FabricClientInitializer implements ClientModInitializer { private static final String MOD_ID = "fastback"; @Override public void onInitializeClient() { ClientLifecycleEvents.CLIENT_STARTED.register( minecraftClient -> { Mod.initializeForClient(new FabricLoaderHelper(), new ClientHelper(minecraftClient)); HudElementRegistry.addLast( Identifier.fromNamespaceAndPath(MOD_ID, "hud"), (guiGraphics, deltaTracker) -> Mod.mod().renderHud(guiGraphics) ); } ); ServerLifecycleEvents.SERVER_STARTING.register( minecraftServer -> Mod.mod().onWorldStart(minecraftServer) ); ServerLifecycleEvents.SERVER_STOPPED.register( minecraftServer -> Mod.mod().onWorldStop() ); } } ================================================ FILE: fabric/src/main/java/net/pcal/fastback/fabric/FabricLoaderHelper.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.fabric; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import me.lucko.fabric.api.permissions.v0.Permissions; import net.fabricmc.api.EnvType; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import net.minecraft.commands.CommandSourceStack; import net.pcal.fastback.common.commands.PermissionsFactory; import net.pcal.fastback.common.mod.LoaderHelper; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * Base Fabric implementation of {@link LoaderHelper}. * * @author pcal * @since 0.1.0 */ class FabricLoaderHelper implements LoaderHelper { private static final String FABRIC_MOD_ID = "fastback"; @Override public String getModVersion() { return FabricLoader.getInstance().getModContainer(FABRIC_MOD_ID) .orElseThrow(() -> new IllegalStateException("Could not find loader for " + FABRIC_MOD_ID)) .getMetadata().getVersion().toString(); } @Override public void addLoaderBackupProperties(Map props) { try { final List modList = new ArrayList<>(); for (final ModContainer mc : FabricLoader.getInstance().getAllMods()) { modList.add(mc.getMetadata().getId() + ':' + mc.getMetadata().getVersion()); } Collections.sort(modList); final StringBuilder modListProp = new StringBuilder(); for (final String mod : modList) modListProp.append(mod).append(", "); props.put("fabric-mods", modListProp.toString()); } catch (Exception ohwell) { syslog().error(ohwell); } } @Override public Path getSavesDir() { if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) { return FabricLoader.getInstance().getGameDir().resolve("saves"); } return null; } @Override public Collection getModsBackupPaths() { final Path gameDir = FabricLoader.getInstance().getGameDir(); final List out = new ArrayList<>(); out.add(gameDir.resolve("options.txt")); out.add(gameDir.resolve("mods")); out.add(gameDir.resolve("config")); out.add(gameDir.resolve("resourcepacks")); return out; } @Override public void registerBackupCommand(boolean isForClient, Function, LiteralArgumentBuilder> builder) { final int requiredLevel = isForClient ? 0 : 4; LiteralArgumentBuilder backupCommand = builder.apply(permName -> Permissions.require(permName, requiredLevel)); CommandRegistrationCallback.EVENT.register((dispatcher, regAccess, env) -> dispatcher.register(backupCommand)); } } ================================================ FILE: fabric/src/main/java/net/pcal/fastback/fabric/FabricServerInitializer.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2022 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.fabric; import net.fabricmc.api.DedicatedServerModInitializer; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.pcal.fastback.common.mod.Mod; /** * Initializer that runs on a dedicated server. * * @author pcal * @since 0.0.1 */ public class FabricServerInitializer implements DedicatedServerModInitializer { @Override public void onInitializeServer() { Mod.initializeForDedicatedServer(new FabricLoaderHelper()); ServerLifecycleEvents.SERVER_STARTING.register( minecraftServer -> Mod.mod().onWorldStart(minecraftServer) ); ServerLifecycleEvents.SERVER_STOPPED.register( minecraftServer -> Mod.mod().onWorldStop() ); } } ================================================ FILE: fabric/src/main/resources/fabric.mod.json ================================================ { "schemaVersion": 1, "id": "fastback", "version": "${mod_version}", "name": "FastBack", "description": "Fast, incremental world backups powered by git", "authors": ["pcal.net"], "contact": { "homepage": "https://pcal43.github.io/fastback/", "sources": "https://github.com/pcal43/fastback" }, "license": "GPL-2", "icon": "fastback-icon.png", "environment": "*", "entrypoints": { "client": [ "net.pcal.fastback.fabric.FabricClientInitializer" ], "server": [ "net.pcal.fastback.fabric.FabricServerInitializer" ] }, "mixins": [ "fastback.mixins.json" ], "depends": { "fabricloader": ">=${fabric_loader_version}", "fabric-api": "*", "fabric-permissions-api-v0": "*", "minecraft": "26.1.x", "java": ">=25" }, "conflicts": {} } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # # Mod # mod_version = 0.33.1+26.1.2-prerelease archives_base_name = fastback github_projectUrl = https://github.com/pcal43/fastback modrinth_projectId = ZHKrK8Rp curseforge_projectId = 667417 # # Minecraft # minecraft_version=26.1.2 java_version = 25 # # Fabric - https://fabricmc.net/develop # fabric_loader_version=0.19.1 fabric_loom_version=1.16-SNAPSHOT fabric_api_version=0.145.4+26.1.2 # # NeoForge - https://neoforged.net/ # neoforge_version = 26.1.2.8-beta neoforge_moddev_version = 2.0.140 # # Dependencies # # https://plugins.gradle.org/plugin/net.darkhax.curseforgegradle curseforgegradle_plugin_version = 1.1.25 # https://plugins.gradle.org/plugin/com.modrinth.minotaur minotaur_plugin_version = 2.9.0 # https://mvnrepository.com/artifact/org.spongepowered/mixin spongepowered_version = 0.8.5 # https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter/versions junit_version = 5.14.4 junit_platform_version = 1.14.4 # https://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit jgit_version = 7.4.0.202509020913-r # https://mvnrepository.com/artifact/org.apache.sshd/sshd-core apache_sshd_version = 2.16.0 # https://mvnrepository.com/artifact/com.googlecode.javaewah/JavaEWAH JavaEWAH_version = 1.2.3 # https://mvnrepository.com/artifact/net.i2p.crypto/eddsa eddsa_version = 0.3.0 # https://github.com/lucko/fabric-permissions-api/releases fabric_permissions_version = 0.7.0 # https://github.com/NucleoidMC/Server-Translations/releases # https://maven.nucleoid.xyz/xyz/nucleoid/server-translations-api/ server_translations_version = 3.0.3+26.1 # # Build settings # org.gradle.daemon = true org.gradle.parallel = true org.gradle.jvmargs = -Xmx4G ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s ' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: neoforge/build.gradle ================================================ plugins { id 'net.neoforged.moddev' id 'com.modrinth.minotaur' id 'net.darkhax.curseforgegradle' } base { archivesName = "${rootProject.ext.archivesBaseNameNeoForge}-${project.mod_version}" } repositories { mavenCentral() maven { url = 'https://maven.neoforged.net/' } } dependencies { // common module - needed for IntelliJ to resolve the project dependency compileOnly project(':common') // jgit and dependencies implementation("org.eclipse.jgit:org.eclipse.jgit:${jgit_version}") { transitive = false } jarJar("org.eclipse.jgit:org.eclipse.jgit:${jgit_version}") { transitive = false } implementation("com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}") { transitive = false } jarJar("com.googlecode.javaewah:JavaEWAH:${JavaEWAH_version}") { transitive = false } implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}") { transitive = false } jarJar("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:${jgit_version}") { transitive = false } implementation("org.apache.sshd:sshd-core:${apache_sshd_version}") { transitive = false } jarJar("org.apache.sshd:sshd-core:${apache_sshd_version}") { transitive = false } implementation("org.apache.sshd:sshd-common:${apache_sshd_version}") { transitive = false } jarJar("org.apache.sshd:sshd-common:${apache_sshd_version}") { transitive = false } implementation("net.i2p.crypto:eddsa:${eddsa_version}") { transitive = false } jarJar("net.i2p.crypto:eddsa:${eddsa_version}") { transitive = false } } neoForge { version = "${neoforge_version}" runs { client { client() ideName = "NeoForge Client" gameDirectory = project.file("run") } server { server() ideName = "NeoForge Server" gameDirectory = project.file("run") } } mods { fastback { sourceSet sourceSets.main } } } sourceSets { main { java { srcDir project(":common").file("src/main/java") } resources { srcDir project(":common").file("src/main/resources") } } } processResources { inputs.property "mod_version", mod_version inputs.property "minecraft_version", minecraft_version inputs.property "neoforge_version", neoforge_version filesMatching("META-INF/neoforge.mods.toml") { expand "mod_version": mod_version, "minecraft_version": minecraft_version, "neoforge_version": neoforge_version } } jar { from("LICENSE") { rename { "${it}_${archives_base_name}" } } manifest { attributes([ 'MixinConfigs': 'fastback.mixins.json' ]) } } // Publishing configuration modrinth { token = System.getenv("MODRINTH_TOKEN") projectId = modrinth_projectId versionNumber = mod_version versionName = "${mod_version} (neoforge)" versionType = "release" uploadFile = jar changelog = "

${github_projectUrl}/releases/tag/${mod_version}

" gameVersions = [minecraft_version] loaders = ["neoforge"] } import net.darkhax.curseforgegradle.TaskPublishCurseForge tasks.register('publishCurseForge', TaskPublishCurseForge) { apiToken = System.getenv("CURSEFORGE_TOKEN") ?: 'CURSEFORGE_TOKEN NOT_SET' def mainFile = upload(curseforge_projectId, jar) mainFile.releaseType = "release" mainFile.changelog = "${github_projectUrl}/releases/tag/${mod_version}" mainFile.changelogType = "markdown" mainFile.addGameVersion(minecraft_version) mainFile.addModLoader("NeoForge") dependsOn(jar) } ================================================ FILE: neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeClientInitializer.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2026 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.neoforge; import net.minecraft.client.Minecraft; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.client.event.ClientTickEvent; import net.neoforged.neoforge.client.event.RenderGuiLayerEvent; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppedEvent; import net.pcal.fastback.common.mod.ClientHelper; import net.pcal.fastback.common.mod.Mod; import static net.pcal.fastback.common.mod.Mod.mod; /** * Client-side NeoForge initialization. Kept separate from NeoForgeModInitializer * so that client-only classes are not classloaded on a dedicated server. * * @author pcal */ class NeoForgeClientInitializer { static void init(IEventBus modEventBus) { final boolean[] initialized = {false}; // We need the Minecraft client instance to build ClientHelper, so we defer until the first tick. NeoForge.EVENT_BUS.addListener((ClientTickEvent.Pre event) -> { if (!initialized[0]) { initialized[0] = true; final Minecraft client = Minecraft.getInstance(); Mod.initializeForClient(new NeoForgeLoaderHelper(true), new ClientHelper(client)); } }); NeoForge.EVENT_BUS.addListener((RenderGuiLayerEvent.Post event) -> mod().renderHud(event.getGuiGraphics())); NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> mod().onWorldStart(event.getServer())); NeoForge.EVENT_BUS.addListener((ServerStoppedEvent event) -> mod().onWorldStop()); } } ================================================ FILE: neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeLoaderHelper.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2026 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.neoforge; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.permissions.Permission; import net.minecraft.server.permissions.PermissionLevel; import net.neoforged.fml.ModList; import net.neoforged.fml.loading.FMLPaths; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.pcal.fastback.common.commands.PermissionsFactory; import net.pcal.fastback.common.mod.LoaderHelper; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; import static net.pcal.fastback.common.logging.SystemLogger.syslog; /** * NeoForge implementation of {@link LoaderHelper}. * * @author pcal */ class NeoForgeLoaderHelper implements LoaderHelper { static final String NEOFORGE_MOD_ID = "fastback"; private final boolean isClient; NeoForgeLoaderHelper(boolean isClient) { this.isClient = isClient; } @Override public String getModVersion() { return ModList.get().getModContainerById(NEOFORGE_MOD_ID) .map(c -> c.getModInfo().getVersion().toString()) .orElseThrow(() -> new IllegalStateException("Could not find mod container for " + NEOFORGE_MOD_ID)); } @Override public void addLoaderBackupProperties(Map props) { try { final List modList = new ArrayList<>(); ModList.get().getMods().forEach(info -> modList.add(info.getModId() + ':' + info.getVersion())); Collections.sort(modList); final StringBuilder modListProp = new StringBuilder(); for (final String mod : modList) modListProp.append(mod).append(", "); props.put("neoforge-mods", modListProp.toString()); } catch (Exception ohwell) { syslog().error(ohwell); } } @Override public Path getSavesDir() { return isClient ? FMLPaths.GAMEDIR.get().resolve("saves") : null; } @Override public Collection getModsBackupPaths() { final Path gameDir = FMLPaths.GAMEDIR.get(); final List out = new ArrayList<>(); out.add(gameDir.resolve("options.txt")); out.add(gameDir.resolve("mods")); out.add(gameDir.resolve("config")); out.add(gameDir.resolve("resourcepacks")); return out; } @Override public void registerBackupCommand(boolean isForClient, Function, LiteralArgumentBuilder> builder) { final int requiredLevel = isForClient ? 0 : 4; final PermissionLevel permLevel = PermissionLevel.byId(requiredLevel); final LiteralArgumentBuilder backupCommand = builder.apply(permName -> source -> source.permissions().hasPermission(new Permission.HasCommandLevel(permLevel))); NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> { event.getDispatcher().register(backupCommand); syslog().debug("registered backup command"); }); } } ================================================ FILE: neoforge/src/main/java/net/pcal/fastback/neoforge/NeoForgeModInitializer.java ================================================ /* * FastBack - Fast, incremental Minecraft backups powered by Git. * Copyright (C) 2026 pcal.net * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; If not, see . */ package net.pcal.fastback.neoforge; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppedEvent; import static net.pcal.fastback.common.mod.Mod.mod; import static net.pcal.fastback.neoforge.NeoForgeLoaderHelper.NEOFORGE_MOD_ID; /** * NeoForge mod entry point. Handles both dedicated server and client environments. * * @author pcal */ @Mod(NEOFORGE_MOD_ID) public class NeoForgeModInitializer { public NeoForgeModInitializer(IEventBus modEventBus, ModContainer modContainer, Dist dist) { if (dist == Dist.CLIENT) { NeoForgeClientInitializer.init(modEventBus); } else { net.pcal.fastback.common.mod.Mod.initializeForDedicatedServer(new NeoForgeLoaderHelper(false)); NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> mod().onWorldStart(event.getServer())); NeoForge.EVENT_BUS.addListener((ServerStoppedEvent event) -> mod().onWorldStop()); } } } ================================================ FILE: neoforge/src/main/resources/META-INF/neoforge.mods.toml ================================================ modLoader = "javafml" loaderVersion = "[4,)" license = "GPL-2" [[mixins]] config = "fastback.mixins.json" [[mods]] modId = "fastback" version = "${mod_version}" displayName = "FastBack" description = "Fast, incremental world backups powered by git" logoFile = "fastback-icon.png" [[dependencies.fastback]] modId = "neoforge" type = "required" versionRange = "[${neoforge_version},)" ordering = "NONE" side = "BOTH" [[dependencies.fastback]] modId = "minecraft" type = "required" versionRange = "[${minecraft_version},)" ordering = "NONE" side = "BOTH" ================================================ FILE: neoforge/src/main/resources/pack.mcmeta ================================================ { "pack": { "description": "FastBack NeoForge Resources", "pack_format": 34 } } ================================================ FILE: settings.gradle ================================================ pluginManagement { repositories { mavenCentral() gradlePluginPortal() maven { url = 'https://maven.fabricmc.net/' } maven { url = 'https://maven.neoforged.net/' } } } rootProject.name = 'fastback' include 'common' include 'fabric' include 'neoforge'