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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ This is another pre-release that adds new features.

- Support multiple targets: auto-discover the proton prefix and allow arbitrary
custom-path targets.
- Add mod deploy rules: skip-backup and write-once.
- Add backups management commands.
- Add `profiles preview` to see the file contents that would be written on
apply.

## v0.5.0 - 2026-03-27

Expand Down
192 changes: 179 additions & 13 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,16 @@ SQLite stores:
- mode pages, mode files, mod file versions
- profiles and their enabled mod file versions + priority
- remap configurations
- deployment rules (`profile_item_skip_backup_patterns`,
`profile_item_write_once_patterns`)
- file manifests (planned + installed)
- installed file hashes and ownership
- backup mappings
- backup management is tracked in `backups` with one row per
`(game_install_id, target_id, relpath)`; rows are removed explicitly via
`games backups delete` or implicitly on unapply restore
- operation journal/logs
- override/merge policy data structures (even if unused in v1)
- override/merge policy data structures (even if merge policy unused in v1)
- blob references

Version schema from day 1.
Expand Down Expand Up @@ -577,7 +582,143 @@ remap configs, there is no caching of remap results. The planner always derives
the final destination path set fresh from the active profile's remap rules at
plan time.

## 9. User Overrides / Editable Files
## 9. Deployment Rules

Deployment rules are per-path pattern lists attached to `profile_items`. Like
remap rules, they are profile-scoped: the same mod in two different profiles
can have different deployment rules. Patterns are evaluated against the final
remapped destination path (post-remap, pre-target-root) — the same coordinate
space as conflict detection.

Only the winning mod's rules apply to a given path. A losing mod's deployment
rules are irrelevant since its file is not written to disk.

Deployment rules do not apply to override-owned paths. Overrides always follow
normal backup and deployment behavior.

### Skip-backup patterns

A skip-backup pattern suppresses all backup behavior for matched paths:

- The initial backup of a pre-existing non-tool-owned file is skipped.
- Drift backups (backing up externally modified tool-owned content before
overwriting) are skipped.
- On unapply, matched paths have no backup to restore and are deleted.

Use this for files that change frequently (e.g. cache files generated by the
game or mod) where accumulating backups is undesirable. The user accepts that
matched paths cannot be restored by modctl on unapply; Steam's "verify
integrity of game files" can be used to recover game-owned files if needed.

### Write-once patterns

A write-once pattern deploys a file on first apply and then leaves it
untouched on subsequent applies, preserving any in-game changes made after
the initial deployment:

- If the file is present on disk and tool-owned, it is skipped (noop)
regardless of drift. An informational warning is emitted if the on-disk
content differs from the installed hash, but no action is taken.
- If the file is missing from disk it is re-deployed as a normal write.
Write-once does not mean "deploy exactly once ever", it means "don't
overwrite changes the game has made."
- The initial backup of a pre-existing non-tool-owned file is still performed,
so unapply can restore the original file correctly.

Use this for config files that the game modifies at runtime (e.g. settings
changed via an in-game menu) where you want the mod to provide the initial
defaults without overwriting the player's subsequent changes.

### Combining rules

Both rules can be applied to the same path. The resulting behavior is:

- First apply: deploy the file, skip the initial backup.
- Subsequent applies: skip entirely if present on disk.
- Unapply: delete (no backup to restore).

### Commands
```
profiles deploys skip-backup add|remove|list|copy <mod_file_version_id> <pattern>
profiles deploys write-once add|remove|list|copy <mod_file_version_id> <pattern>
```

When upgrading a mod version via `profiles upgrade`, all deployment rules are
preserved automatically since the profile item is updated in place. Manual
copying via the `copy` subcommand is only needed when moving rules between
two distinct profile items.

## 10. Backup inspection and management

The `backups` table records pre-existing files that modctl saved before
overwriting them. Backups are created automatically during apply and restored
automatically during unapply. The backup management commands allow users to
inspect, diff, restore, and delete individual backup entries without running
a full unapply.

### Scope

Backups are scoped to `(game_install_id, target_id, relpath)` with no
reference to a profile. This reflects the fact that backups describe
pre-mod on-disk state which is independent of which profile caused the
overwrite. The `games backups` commands operate at the game level and
optionally filter by target.

### Backup lifecycle

A backup row is created when apply would overwrite a file that modctl did
not install (i.e. a non-tool-owned file). The blob is content-addressed and
deduplicated; if the same file content is backed up multiple times only one
blob is stored. The backup row is removed when unapply restores the file, or
explicitly via `games backups delete`.

Deleting a backup row does not immediately remove the blob from disk; run
`gc` to reclaim space. Deleting a backup means modctl cannot restore the
original file at that path on unapply — the file will be deleted instead.

### Out-of-band restore

`games backups restore` writes the backup content back to disk immediately
without running a full unapply. This is useful for reverting a single file
to its pre-mod state without touching everything else. It does not update
`installed_files` (the profile's desired state is unchanged), so the next
apply will detect drift and overwrite the restored file again. The command
warns when the active profile is currently applied and suggests adding a
write-once or skip-backup rule if the user wants to preserve the restored
state permanently.

Restore requires `--force` if the on-disk file has drifted from what modctl
last installed, since this indicates an external modification that the user
should be aware of before overwriting.

No operation record is created for out-of-band restores. The next apply will
surface the path as drifted, providing an implicit audit trail.

### Binary detection

The `view` and `diff` commands detect binary content by scanning the first
8KB of file data for null bytes, matching the heuristic used by Git. Binary
files are refused by default with a clear error message. Pass `--force` to
proceed anyway.

### Diff commands

Two diff commands are available:

**`games backups diff <path>`** shows a unified diff between the backup blob
and the current on-disk content. Direction: backup → on-disk. Shows what has
changed since the backup was taken. If the on-disk file is missing, the
backup content is shown as a full deletion with a warning.

**`profiles preview <path>`** shows a unified diff between the current
on-disk content and what the active profile's winning mod would write at that
path. Direction: on-disk → incoming. Shows what apply would do to the file.
Requires archive extraction and may be slow for large archives. Errors if no
mod in the active profile provides the path. This command is profile-scoped
and lives under `profiles` rather than `games backups` since it requires
resolving the conflict winner from a specific profile's planned state.

## 11. User Overrides / Editable Files

### Goal

Expand Down Expand Up @@ -879,7 +1020,7 @@ For game-scoped import, `overrides` and `override_patch_entries` are added to
the ID remapping table. `overrides copy` copies override rows and patch entries
in a single transaction, preserving source anchor fields verbatim.

## 10. Mod Incompatibilities
## 12. Mod Incompatibilities

### Purpose

Expand Down Expand Up @@ -923,7 +1064,7 @@ implicit since mod page IDs are globally unique and carry their own
`game_install_id`. For `list` the current game install context is used to
scope results.

## 11. Backups strategy
## 13. Backups strategy

### When to back up

Expand All @@ -943,7 +1084,7 @@ On unapply/rollback:
- if user changed file since backup, require explicit choice (or use hash
checks)

## 12. Multi-store support
## 14. Multi-store support

### Store integration responsibilities

Expand Down Expand Up @@ -991,7 +1132,7 @@ absolute path is stored as `root_path` with `origin = 'discovered'`. If a
that have never been launched under Proton, or native Linux games, will not
have a `proton_prefix` target.

## 13. Extensibility for game-specific integrations
## 15. Extensibility for game-specific integrations

### Integration type

Expand All @@ -1013,7 +1154,7 @@ Game-specific integrations add/override:

This preserves a clean v1 while allowing richer v2.

## 14. Commands
## 16. Commands

- `init` initialized the modctl database and storage directories
- `auth`
Expand Down Expand Up @@ -1060,6 +1201,20 @@ This preserves a clean v1 while allowing richer v2.
run.
- `profiles overrides set|edit|status|unset|list|copy` - manage mod overrides
- `profiles overrides patch set|unset|remove|list|preview` - manage structured mod overrides
- `profiles deploys skip-backup add|remove|list|copy` - manage skip-backup patterns
for a mod version within a profile. Patterns are evaluated against the final
remapped destination path. Files matching a skip-backup pattern are never
backed up during apply, including the initial backup of pre-existing files.
Use `copy` to transfer patterns from one mod version to another (e.g. when
manually swapping versions).
- `profiles deploys write-once add|remove|list|copy` - manage write-once patterns
for a mod version within a profile. Patterns are evaluated against the final
remapped destination path. Files matching a write-once pattern are deployed
on first apply and left untouched on subsequent applies unless missing from
disk. Use `copy` to transfer patterns from one mod version to another.
- `profiles preview <path>` - show a unified diff between the current
on-disk file and what the active profile's winning mod would write at that
path; requires archive extraction which may be slow for large archives
- `policy set` (future: merge/manual policy)
- `status` (conflicts, drift, missing)
- `apply` (top-level) - apply the active profile to the game directory.
Expand Down Expand Up @@ -1145,6 +1300,17 @@ This preserves a clean v1 while allowing richer v2.
- `games targets remove <name>` - remove a user-defined target. Refuses if
any installed files reference the target (unapply first). Cascades to
profile items referencing the target. Cannot remove auto-discovered targets.
- `games backups list` - list all backed-up files for the current game,
optionally filtered by target
- `games backups view <path>` - print the content of a backed-up file to
the terminal; binary files are refused unless `--force` is passed
- `games backups delete <path>` - delete a backup entry; warns that unapply
will delete rather than restore the file at this path
- `games backups restore <path>` - restore a backed-up file to disk
immediately without running a full unapply; warns if the active profile
is applied; requires `--force` if the on-disk file has drifted
- `games backups diff <path>` - show a unified diff between the backup blob
and the current on-disk file

Key behavior:
- "intent changes" (enable/disable/order) are cheap
Expand Down Expand Up @@ -1228,7 +1394,7 @@ This check detects added, removed, or swapped mod versions but does not
detect priority reordering between mods that conflict on the same path. Run
`apply --dry-run` for a precise diff.

## 15. Testing strategy
## 17. Testing strategy

### Unit tests

Expand Down Expand Up @@ -1275,7 +1441,7 @@ Include in `testdata/`:
entries, malformed lines) should be tested at the unit level and included
in `testdata/` fixture archives for integration tests

## 16. Operational considerations
## 18. Operational considerations

- lock per game during apply to avoid concurrent changes
- refuse to operate if game is running (optional v1, but helpful)
Expand All @@ -1285,7 +1451,7 @@ Include in `testdata/`:
of any apply/unapply command, refusing to proceed without `--force` or
`--abort`

## 17. Configuration
## 19. Configuration

### File location

Expand Down Expand Up @@ -1340,7 +1506,7 @@ custom formatting added by hand will not be preserved.
if it does not exist; prints a plain-text storage notice when setting
`nexus.apikey`

## 18. Nexus Mods integration
## 20. Nexus Mods integration

### Overview

Expand Down Expand Up @@ -1450,7 +1616,7 @@ The API key is read from config (`nexus.apikey`). If not configured, Nexus
linking is silently skipped at import time; commands that require it error
with a helpful message.

## 19. Garbage Collection
## 21. Garbage Collection

### Purpose

Expand Down Expand Up @@ -1508,7 +1674,7 @@ Flags:
- `--skip-orphans`: skip on-disk files with no database row (orphans are
removed by default)

## 20. Export and Import
## 22. Export and Import

### Purpose

Expand Down
4 changes: 4 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,8 @@ func printApplyPlan(
if op.NeedsBackup {
detail = subtle.Render("(backup needed)")
countBackup++
} else if op.SkipBackup {
detail = subtle.Render("(skip-backup)")
}
winner := op.File.Winner()
modInfo := formatModInfo(winner)
Expand All @@ -594,6 +596,8 @@ func printApplyPlan(
if op.NeedsBackup {
detail = subtle.Render("(backup needed)")
countBackup++
} else if op.SkipBackup {
detail = subtle.Render("(skip-backup)")
}
winner := op.File.Winner()
modInfo := formatModInfo(winner)
Expand Down
37 changes: 37 additions & 0 deletions cmd/games_backups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* mod control (modctl): command-line mod manager
* Copyright © 2026 Mario Finelli
*
* 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 3 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 <https://www.gnu.org/licenses/>.
*/

package cmd

import (
"github.com/spf13/cobra"
)

var gamesBackupsCmd = &cobra.Command{
Use: "backups",
Short: "Inspect and manage backed-up game files",
Long: `Inspect and manage files that modctl backed up before overwriting them.

Backups are created automatically when modctl overwrites a file it did not
install. They are restored automatically on unapply. These commands let you
inspect, preview, and manage individual backup entries.`,
}

func init() {
gamesCmd.AddCommand(gamesBackupsCmd)
}
Loading
Loading