Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.3.7.1 (2026-09-10)

### Fixed

- Storage cleanup now deletes the backups it lists. Before, it only removed
the ZIP copy of a backup and left the Git snapshot and the catalog entry in
place, so the backup was still there after the cleanup and after a restart.
A backup the keep settings do not protect is now deleted the same way the
Delete button deletes it: Git snapshot, remote copy, ZIP, and catalog entry.
- Cleanup frees the Git LFS space of deleted snapshots. Only the objects a
remaining snapshot still points at are kept.
- A protected backup that loses its local Git copy keeps its synchronized
remote copy in the catalog, so it stays visible and can be deleted later.

## 0.3.7 (2026-09-10)

### Added
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ Guided cleanup keeps:
Within a period, manual backups and backups with more changed files win.
Cleanup always shows a preview first. Nothing is deleted until you confirm.

Cleanup deletes the backups it lists the same way the Delete button does: the
Git snapshot, its copy on the configured remote, the ZIP, and the catalog
entry. A protected backup is never deleted. When space is still short after
that, cleanup can drop the local Git copies of protected backups, but only when
each of them keeps a ZIP or a verified remote copy.

## Minecraft versions

Every new backup records the Minecraft version it was made with. The restore
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ loom_version=1.17.19
fabric_api_version=0.157.0+26.2
modmenu_version=20.0.1

mod_version=0.3.7
mod_version=0.3.7.1
maven_group=dev.ishaanko
automatic_release_channel=alpha
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ final class CleanupConfirmationScreen extends Screen {
BackupClientFacade facade,
CleanupPlan plan,
Set<BackupId> selected) {
super(Component.literal("Confirm Local Cleanup"));
super(Component.literal("Confirm Cleanup"));
this.preview = Objects.requireNonNull(preview, "preview");
this.returnTo = Objects.requireNonNull(returnTo, "returnTo");
this.world = Objects.requireNonNull(world, "world");
Expand All @@ -76,7 +76,7 @@ protected void init() {
contentWidth,
18,
Component.literal(
"These backups will be deleted from this computer. This cannot be undone.")
"These backups will be deleted. This cannot be undone.")
.withStyle(ChatFormatting.RED),
font));
int pageSize = Math.max(1, Math.min(6, (height - 142) / 24));
Expand All @@ -91,8 +91,9 @@ protected void init() {
+ " 路 "
+ item.label().orElse("unlabeled")
+ " 路 "
+ (item.removeLocalGit() ? "Git " : "")
+ (item.removeZip() ? "ZIP" : "");
+ (plan.protectedBackups().contains(item.backupId())
? "local Git copy only"
: (item.removeGit() ? "Git " : "") + (item.removeZip() ? "ZIP" : ""));
StringWidget row = new StringWidget(
x,
y,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ protected void init() {
addRenderableOnly(Widgets.title(font, x, 9, contentWidth, 20, title));
String summary = plan.items().isEmpty()
? "Nothing to clean up right now. Your keep settings protect every backup."
: "Choose which backups to delete from this computer. Copies on GitHub or in linked folders are not touched.";
: "Backups your keep settings do not protect are deleted everywhere, including GitHub. Protected backups only lose their local Git copy.";
addRenderableOnly(new MultiLineTextWidget(
x,
31,
Expand All @@ -89,7 +89,7 @@ private void addItems(int x, int contentWidth, int pageSize) {
+ " 路 "
+ identity(item)
+ " 路 "
+ actions(item)
+ actions(plan, item)
+ " 路 "
+ item.changedFileCount()
+ " changed";
Expand All @@ -105,9 +105,10 @@ private void addItems(int x, int contentWidth, int pageSize) {
}
}

/** Protected backups give up their local Git copies together or not at all. */
private void toggle(CleanupItem item) {
boolean removing = selected.contains(item.backupId());
if (!item.removeLocalGit()) {
if (!item.removeGit() || !plan.protectedBackups().contains(item.backupId())) {
if (removing) {
selected.remove(item.backupId());
} else {
Expand All @@ -116,8 +117,9 @@ private void toggle(CleanupItem item) {
return;
}
plan.items().stream()
.filter(CleanupItem::removeLocalGit)
.filter(CleanupItem::removeGit)
.map(CleanupItem::backupId)
.filter(plan.protectedBackups()::contains)
.forEach(backupId -> {
if (removing) {
selected.remove(backupId);
Expand Down Expand Up @@ -192,11 +194,14 @@ private static String identity(CleanupItem item) {
return item.label().orElse(item.backupId().toString().substring(0, 8));
}

private static String actions(CleanupItem item) {
if (item.removeLocalGit() && item.removeZip()) {
return "Git + ZIP";
private static String actions(CleanupPlan plan, CleanupItem item) {
if (plan.protectedBackups().contains(item.backupId())) {
return "local Git copy only";
}
return item.removeLocalGit() ? "local Git" : "ZIP";
if (item.removeGit() && item.removeZip()) {
return "delete Git + ZIP";
}
return item.removeGit() ? "delete Git" : "delete ZIP";
}

static String details(CleanupItem item) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,17 +246,18 @@ public CompletionStage<Boolean> deleteLocalSnapshot(WorldId worldId, BackupId ba
Objects.requireNonNull(worldId, "worldId");
Objects.requireNonNull(backupId, "backupId");
return submit(() -> lock.withLock(() ->
new GitStorageCompactor(settings, repository, refs, commands)
new GitStorageCompactor(settings, repository, refs, commands, verifier)
.deleteLocalSnapshot(worldId, backupId)));
}

/** Frees Git and LFS storage that no remaining snapshot in this repository uses. */
public CompletionStage<Void> compactStorage(WorldId worldId) {
Objects.requireNonNull(worldId, "worldId");
return submit(() -> lock.withLock(() -> {
repository.requireWorld(worldId);
repository.requireBare();
boolean noSnapshots = operations.listSnapshotsBlocking(Optional.of(worldId)).isEmpty();
new GitStorageCompactor(settings, repository, refs, commands).compact(worldId, noSnapshots);
new GitStorageCompactor(settings, repository, refs, commands, verifier)
.compact(worldId, operations.listSnapshotsBlocking(Optional.empty()));
return null;
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ private void requireCommitMessage(
}
}

/**
* Lists the LFS objects a snapshot tree points at without checking that the
* objects exist. Compaction uses this to decide which objects must stay.
*/
List<GitLfsPointer> readLfsPointers(String commit)
throws IOException, InterruptedException, GitStorageException {
List<GitLfsPointer> pointers = new ArrayList<>();
for (GitTreeEntry entry : readTreeEntries(commit)) {
if (entry.path().equals(GitBackupBackend.MANIFEST_PATH)) {
continue;
}
readBlob(entry).pointer().ifPresent(pointers::add);
}
return List.copyOf(pointers);
}

private List<GitLfsPointer> findAndVerifySnapshotFiles(
List<GitTreeEntry> treeEntries,
BackupManifest manifest)
Expand All @@ -217,26 +233,9 @@ private List<GitLfsPointer> findAndVerifySnapshotFiles(
if (entry.path().equals(GitBackupBackend.MANIFEST_PATH)) {
continue;
}
GitCommandResult contents = commands.run(
List.of(
"cat-file",
"blob",
entry.objectId()),
settings.repository(),
Map.of(),
new byte[0],
LFS_POINTER_OUTPUT_BYTES);
if (!contents.successful()) {
throw new GitStorageException(GitCommands.failureMessage(contents));
}
if (contents.standardErrorTruncated()) {
throw new GitStorageException(
"Git LFS pointer inspection exceeded its safety limit");
}
Optional<GitLfsPointer> pointer = GitLfsPointer.parse(
entry,
contents.standardOutput(),
contents.standardOutputTruncated());
SnapshotBlob blob = readBlob(entry);
GitCommandResult contents = blob.contents();
Optional<GitLfsPointer> pointer = blob.pointer();
if (pointer.isPresent()) {
verifyLfsObject(pointer.get());
pointers.add(pointer.get());
Expand All @@ -255,6 +254,31 @@ private List<GitLfsPointer> findAndVerifySnapshotFiles(
return List.copyOf(pointers);
}

/** Reads one tree blob up to the pointer size limit and parses it as an LFS pointer. */
private SnapshotBlob readBlob(GitTreeEntry entry)
throws IOException, InterruptedException, GitStorageException {
GitCommandResult contents = commands.run(
List.of(
"cat-file",
"blob",
entry.objectId()),
settings.repository(),
Map.of(),
new byte[0],
LFS_POINTER_OUTPUT_BYTES);
if (!contents.successful()) {
throw new GitStorageException(GitCommands.failureMessage(contents));
}
if (contents.standardErrorTruncated()) {
throw new GitStorageException(
"Git LFS pointer inspection exceeded its safety limit");
}
return new SnapshotBlob(contents, GitLfsPointer.parse(
entry,
contents.standardOutput(),
contents.standardOutputTruncated()));
}

private void verifyLfsObject(GitLfsPointer pointer) throws IOException, GitStorageException {
Path object = pointer.objectPath(settings.repository());
Path parent = object.getParent();
Expand All @@ -279,6 +303,9 @@ private static String sha256(Path path) throws IOException {
return Digests.sha256(path);
}

private record SnapshotBlob(GitCommandResult contents, Optional<GitLfsPointer> pointer) {
}

record VerifiedSnapshot(
GitSnapshotManifest manifest,
List<GitLfsPointer> lfsPointers) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@
import dev.ishaanko.worldarchive.model.WorldId;
import dev.ishaanko.worldarchive.model.BackupId;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;

/** Exact local-only compaction after confirmed snapshot-ref cleanup. */
final class GitStorageCompactor {
private static final Pattern LFS_OBJECT_NAME = Pattern.compile("[0-9a-f]{64}");

private final GitBackendSettings settings;

private final GitRepositoryManager repository;
Expand All @@ -20,22 +28,41 @@ final class GitStorageCompactor {

private final GitCommands commands;

private final GitSnapshotVerifier verifier;

GitStorageCompactor(
GitBackendSettings settings,
GitRepositoryManager repository,
GitRefStore refs,
GitCommands commands) {
GitCommands commands,
GitSnapshotVerifier verifier) {
this.settings = Objects.requireNonNull(settings, "settings");
this.repository = Objects.requireNonNull(repository, "repository");
this.refs = Objects.requireNonNull(refs, "refs");
this.commands = Objects.requireNonNull(commands, "commands");
this.verifier = Objects.requireNonNull(verifier, "verifier");
}

void compact(WorldId worldId, boolean noSnapshots)
/**
* Reclaims space after snapshot refs were deliberately deleted. Snapshot commits
* chain onto each other, so Git's own reachability keeps every older commit alive
* and {@code git lfs prune} cannot tell a deleted snapshot from a kept one. The LFS
* objects that stay are therefore computed exactly: every object that a remaining
* snapshot's tree points at. Anything else under {@code lfs/objects} is removed.
*
* <p>The world's history ref exists so an import can rebuild snapshot refs that
* went missing by accident. After a deletion it would rebuild the deleted snapshot
* with its content gone, so it is removed here as well.
*/
void compact(WorldId worldId, List<GitSnapshot> remainingSnapshots)
throws IOException, InterruptedException, GitStorageException {
if (noSnapshots) {
refs.deleteIfPresent(repository.historyRef(worldId));
Set<String> retained = new HashSet<>();
for (GitSnapshot snapshot : remainingSnapshots) {
for (GitLfsPointer pointer : verifier.readLfsPointers(snapshot.commitId())) {
retained.add(pointer.sha256());
}
}
refs.deleteIfPresent(repository.historyRef(worldId));
commands.checked(
List.of(
"reflog",
Expand All @@ -52,18 +79,7 @@ void compact(WorldId worldId, boolean noSnapshots)
settings.repository(),
Map.of(),
new byte[0]);
if (noSnapshots) {
deleteAllLfsObjects();
} else {
commands.checked(
List.of(
"lfs",
"prune",
"--force"),
settings.repository(),
Map.of(),
new byte[0]);
}
deleteUnreferencedLfsObjects(retained);
}

boolean deleteLocalSnapshot(WorldId worldId, BackupId backupId)
Expand All @@ -79,11 +95,34 @@ boolean deleteLocalSnapshot(WorldId worldId, BackupId backupId)
return true;
}

private void deleteAllLfsObjects() throws GitStorageException {
private void deleteUnreferencedLfsObjects(Set<String> retained)
throws IOException, GitStorageException {
Path lfsObjects = settings.repository().resolve("lfs").resolve("objects");
GitTemporaryFiles.deleteTree(lfsObjects);
if (Files.exists(lfsObjects, LinkOption.NOFOLLOW_LINKS)) {
throw new GitStorageException("Unreferenced Git LFS objects could not be removed");
if (!Files.isDirectory(lfsObjects, LinkOption.NOFOLLOW_LINKS)) {
return;
}
for (String sha256 : retained) {
Path object = lfsObjects.resolve(sha256.substring(0, 2))
.resolve(sha256.substring(2, 4))
.resolve(sha256);
if (!Files.isRegularFile(object, LinkOption.NOFOLLOW_LINKS)) {
throw new GitStorageException(
"A Git LFS object needed by a remaining snapshot is missing");
}
}
Files.walkFileTree(lfsObjects, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes)
throws IOException {
String name = file.getFileName().toString();
if (attributes.isRegularFile()
&& !attributes.isSymbolicLink()
&& LFS_OBJECT_NAME.matcher(name).matches()
&& !retained.contains(name)) {
Files.delete(file);
}
return FileVisitResult.CONTINUE;
}
});
}
}
Loading
Loading