Skip to content

Releases: Plumvery/UniLFS

v0.5.0 — Track by pattern, not by memory

Choose a tag to compare

@Plumvery Plumvery released this 11 Aug 13:23
e239b59

A project can now say once which files belong in external storage. Until this release that answer only existed in whoever remembered to right-click the asset: a new .psd reached git whenever nobody did, and a new team member had no way to find out what the rule even was.

unilfs.track is that rule written down — a committed plain-text file of gitignore-style patterns, edited in any text editor. It is this package's .gitattributes, except nothing generates it; committing it is what turns "big art files live in storage" into the project's rule rather than one machine's habit.

# UniLFS: files matching a line below live in storage instead of git.
*.psd
*.mp4
Assets/Movies/
!Assets/Movies/thumbs/*.png
Line Means
*.psd no slash: matched against the file name, at any depth
Assets/Movies/ trailing slash: everything under that folder
Assets/**/*.wav ** crosses folders, * stays inside one name, ? is one character
!Assets/UI/*.psd ! excludes — and the last matching line wins, so a later line can re-include
# note comments and blank lines are ignored

What reads it

  • Track Matching (Window > UniLFS) sweeps the whole project and tracks every match nothing tracks yet. Press it after writing the file, or after a git pull brings a teammate's new pattern.
  • Auto Track (Edit > Project Settings > UniLFS, on by default) tracks matching files as they are imported or moved, so a new .psd is out of git from the moment it lands.
  • UniLfsCli.Track does the sweep in batch mode: Unity -batchmode -nographics -quit -executeMethod UniLFS.Editor.UniLfsCli.Track, then ...UniLfsCli.Push. CI no longer needs a hand-written -executeMethod shim to track anything.

Project Settings also gained a Tracked patterns section: how many patterns are in effect, which lines UniLFS could not read, and a button that creates the file from a commented template and opens it in your editor.

What did not change

Both routes end in exactly the same Track as the context menu. Files are staged on this machine and hidden from git, and Push is still the only thing that uploads content and writes a manifest entry — the 0.4.0 rule that an entry exists only once storage has the bytes is untouched. Patterns decide which paths are handed to Track, and nothing else.

The guardrails

  • No line can reach a path Track itself would refuse. Matching runs through the same gate first, so * or ** still never touch a .meta file, the UniLFS files themselves, or anything under Library/, Temp/, Logs/, obj/, UserSettings/ and .git/. There is a test that writes exactly those lines and asserts none of it matches.
  • The sweep reads no file content. It prunes the forbidden trees a directory at a time rather than listing every path under the project root, and files already in the manifest or in staging are counted and skipped — passing them to Track would hash every tracked byte to conclude there was nothing to do.
  • A line UniLFS cannot read says so (a .. segment, say) in Project Settings and in the sweep's errors, rather than quietly matching nothing and looking like a working rule.
  • Matching ignores case, matching the filesystems Unity runs on by default.

Upgrading from 0.4.x

Nothing to do, and nothing changes until you opt in: with no unilfs.track in the project, Auto Track does nothing and everything behaves exactly as in 0.4.1. To start, open Edit > Project Settings > UniLFSTracked patterns → Create, write your patterns, press Track Matching, then Push — and commit unilfs.track along with the manifest.

Untracking a file that a pattern still matches will re-track it on the next import; write a ! line for it instead.

v0.4.1 — A no-change Push makes no storage requests

Choose a tag to compare

@Plumvery Plumvery released this 04 Aug 16:26
59f1764

Push no longer asks storage about blobs this machine already confirmed. Every Push used to make one existence request per tracked blob, changed or not — on Google Drive a files.list query each, behind a token refresh; on S3 a signed HEAD each. Auto Push runs on focus changes, so a project where nothing moved still paid a network round trip per file on every sync.

The proof was already on disk: Library/remote-*.json records every blob this machine uploaded, downloaded, or saw storage confirm. Push loaded that record, wrote to it — and never read it. It now seeds its "present on remote" set from the record and asks only about blobs it has no proof for.

What this means in practice

  • A Push and a Pull where nothing changed make zero storage requests. The whole run is local file stats against the (mtime, size) hash cache.
  • A fresh clone's first Push still checks everything once, earns the confirmations, and only then goes quiet. The record is per machine and per bucket.
  • Changed and new content is checked exactly as before — including the dedup that skips uploading bytes some other path already pushed.

The trade-off, and why it is safe

A blob deleted from the bucket after this machine confirmed it now hides behind the stale record, where the old Push noticed by accident. That case was always Verify's job — Refresh in the window, UniLfsCli.Verify in CI: it asks storage for real, retracts every confirmation storage denies, and the next Push then checks and re-uploads. That loop is pinned by an end-to-end test against a stand-in storage server, alongside tests that a no-change Push makes no requests and that a clone without proof still asks before skipping an upload.

Deleting Library/remote-*.json is also always safe: every confirmation is forgotten, the next Push checks everything once and earns them back.

Upgrading from 0.4.0

Nothing to do. No file formats changed; the confirmation record was already being written by 0.3.x and 0.4.0, so existing machines benefit immediately.

v0.4.0 — Track stages, Push publishes

Choose a tag to compare

@Plumvery Plumvery released this 25 Jul 14:39
3f15b61

Track no longer writes unilfs.manifest.json. It stages the paths on your machine and hides them from git; Push creates the manifest entry, after storage confirms the content.

The manifest is committed to git, and an entry in it is a promise that its blob is downloadable. Track was in no position to make that promise — and two separate failures came out of it making one anyway.

What went wrong before

A committed manifest could name a blob nobody can download. Commit between Track and Push and every teammate gets an entry pointing at an object that is not in the bucket. That is the case the CI verify gate exists to catch after the fact.

And it could lose data. Track also recorded a sync baseline — the manifest hash this machine last agreed with — which for freshly tracked content is an agreement with itself. So when a teammate's manifest arrived naming the same path, the three-way rule read "only the manifest moved" and answered outdated, and Pull replaced a file that existed on exactly one disk and in no bucket.

The split

file committed written by an entry means
unilfs.manifest.json Push only this content was in storage when the entry was written
unilfs.staged.json Track / Push / Pull / Untrack this machine asked to track this path; nothing is uploaded

Staging holds intent only — no hashes, sizes or GUIDs. Push re-reads all three at push time, so anything else kept there would be a second copy of the truth with its own way of going stale, which is the bug being fixed one file over.

A tracked file still has to be invisible to git from the moment you track it, so staged paths are hidden through .git/info/exclude — per-checkout, never tracked by git. The committed .gitignore block stays derived from the manifest alone: deriving a committed file from a local one would make two machines produce different content for it and turn the most boring file in the repository into a source of merge conflicts. On Push the .gitignore line is added before the exclude line is removed.

Also in this release

  • Track is instant. It no longer hashes a file it is not about to upload, so tracking a folder of multi-gigabyte assets no longer reads all of them first.
  • New staged state — tracked here, in no manifest, invisible to everyone else until you Push.
  • Keep Mine, next to Restore Modified: the recorded half of resolving a conflict ("keep mine" vs "take theirs"). Because it is recorded rather than passed to one Push call, Auto Push honours it too. Re-running Track Selected no longer resolves conflicts — that was a committed manifest being rewritten by a command that reads as "start tracking this".
  • A path tracked separately on both sides now reads conflicted, not outdated. Neither Push nor Pull touches it until someone chooses.
  • GUIDs travel from Push, so a re-import that minted a new one reaches the team without anyone re-tracking.
  • Outside a git checkout there is nowhere to write info/exclude. Status reports staged paths that nothing is ignoring rather than pretending they are hidden.

Upgrading from 0.3.3

Nothing to do. unilfs.manifest.json is unchanged — same format, no bump — and the .gitignore block is generated exactly as before, so existing projects see no diff. unilfs.staged.json starts absent, which reads as empty; your first Track creates it.

Downgrading is safe too: an older UniLFS generates the same .gitignore block from the same manifest and leaves the info/exclude block alone, so staged files stay hidden from git. Only the intent to track them is forgotten, and re-running Track restores it.

Rationale, invariants, the full state table and the migration notes: Documentation~/staged-tracking.md.

Tested

110 EditMode tests on Unity 2022.3.62f3, including a Pull/Push suite that runs two clones and a stand-in file server through the state patterns they can end up in — one clone has the file and the other does not, the two disagree about a .meta GUID, the same path holds different bytes — plus the git directory lookup, down to a linked worktree's commondir.

Full changelog: v0.3.3...v0.4.0

v0.3.3 — Pull picks up teammates' updates

Choose a tag to compare

@Plumvery Plumvery released this 22 Jul 14:03
21a8cc3

A teammate updating a file that was already tracked reached nobody.

Pull downloaded only files missing from disk, and an updated file is not missing — it is sitting right there with the old bytes. So Pull skipped it, and Auto Pull never fired at all, because its check was literally "does this path exist?". Push then made it worse: a stale copy and a local edit both read as modified, so Auto Push offered to upload it and Push rewrote the manifest entry to the older local hash — undoing whoever pushed last, in a change git shows as an ordinary one-line manifest diff.

Why it happened

Local hash and manifest hash cannot tell "I edited this" from "someone else pushed a newer one". Both are just local != manifest, and they need opposite fixes.

UniLFS now records the third point: which manifest hash each file was last in sync with on this machine, kept per-machine under Library/UniLFS/, and classifies against it the way a merge base decides which side of a diff actually moved.

State Meaning Fix
modified local moved, manifest didn't Push
outdated manifest moved, local didn't Pull
conflicted both moved resolve by hand

Pull now takes outdated files. Push refuses them and says to Pull. conflicted is left alone by both and resolved explicitly: Restore Modified takes the manifest's version, Track Selected then Push keeps yours — and Track reports when it resolved one that way, since it drops whatever the manifest named.

Also fixed

  • Spurious blue "not pushed" — every status check dropped confirmations for blobs the current manifest did not name, discarding proof that was still true. Files stayed blue until someone pressed Refresh and paid for the round trip again. Confirmations outside the manifest are now kept (most recent 4096) instead of dropped on sight.
  • Assets > UniLFS > Track Selected had the same rollback as Push when run on an already-tracked file.
  • Auto Pull and Auto Push raced on startup — both ran a status check without waiting for the other, and whichever lost the operation lock gave up silently.

Upgrading from 0.3.2

Nothing to do. Baselines are recorded by Track, Push and Pull, and adopted automatically whenever local and manifest already agree, so a project establishes them on its first status check — no re-tracking.

A file that is already diverged at that moment has nothing to adopt. It reads modified (the conservative answer, which never overwrites local content on a guess), and Auto Push skips it rather than guessing the local side is newer. An explicit Push still takes it. Same after deleting Library/UniLFS/.

Full changelog: https://github.com/Plumvery/UniLFS/blob/v0.3.3/CHANGELOG.md
PR: #3

v0.3.2 — Tracked assets keep their .meta GUIDs

Choose a tag to compare

@Plumvery Plumvery released this 22 Jul 09:57

Fixed

  • Tracked assets no longer come back under a new GUID when the project is opened without their content on disk — which silently broke every scene, prefab and Addressables reference pointing at them. Tracked files are gitignored, so a clone gets Foo.mp4.meta but not Foo.mp4; Unity discards a .meta it cannot match to an asset, and mints a fresh GUID once Pull finally brings the file back. Auto Pull could never prevent it: it runs from EditorApplication.delayCall, long after. The manifest now records each tracked file's GUID, and UniLFS puts a discarded .meta back from that record before the asset lands, so the import reuses the identity the rest of the project already references.
  • README.md promised "GUIDs and references never break". That was the intent, not the behaviour.

Added

  • The manifest records each tracked file's Unity GUID next to its hash, and Pull recreates a missing .meta from it before writing the asset. Entries written by earlier versions carry no GUID — re-run Track on them to record one, or a clone still has nothing to restore from.

    A rebuilt .meta carries the GUID but not the original import settings; Unity fills those back in with defaults. Restoring the .meta from git is the better outcome, and UniLFS now warns and says so whenever it had to rebuild one. GUID first because the trade is not symmetric: a wrong GUID breaks references, while default import settings merely look wrong and can be set again.

  • A placeholder is written at every tracked path whose content is missing. It does not win the race against Unity's startup scan — [InitializeOnLoadMethod] is the earliest hook managed code gets, and measured on 2022.3 the scan has already discarded orphaned .meta files by the time it runs, from a warm Library and a deleted one alike. What the placeholder does is keep the window shut afterwards: with an asset at the path, the restored .meta survives later refreshes instead of being discarded and rebuilt on every one, and Pull overwrites the placeholder in place under the same GUID. Expect import errors for those paths until Pull runs — the files really are not there yet.

  • A .meta whose GUID disagrees with the manifest is reported as an error on editor start. It is the signature of this damage having already happened, and is otherwise invisible until something fails to load at runtime.

Changed

  • Manifest entries that do not name a path inside the project are refused with an error instead of being acted on. The manifest is committed, hand-editable and merge-resolved, and the startup guard writes files without anyone asking — so a ../.. entry from a bad merge would otherwise have created files outside the project on editor start.
  • Placeholders read as missing, never as a local modification, everywhere UniLFS looks at a tracked file. Push skips them outright: it rewrites the manifest from whatever it just hashed, so uploading a stand-in would have pointed every clone at it and orphaned the real blob. Untrack clears any placeholder left at the path, which stops being gitignored the moment the entry is removed.

v0.3.0 — Readable progress, honest file states, no overlapping operations

Choose a tag to compare

@Plumvery Plumvery released this 21 Jul 18:13
0c582c4

A UX pass over the editor window and settings page, plus a correctness fix for operations that could run twice.

✨ What's new

📊 Progress that stays readable during parallel transfers

The gauge used to jump around because every worker reported its own view: a stale snapshot of the done counter, its own file name as the label, and its own byte fraction. Progress is now aggregated in one place — phases occupy fixed slices of a single 0→100% run, in-flight bytes are summed rather than overwritten, weighting is by size instead of file count (a 4 KB and a 4 GB file no longer move the bar equally), the label sticks to the longest-running file, and the reported value can never run backwards. The bar also stays live through the status re-check that follows a Push or Pull instead of parking at 100%.

🔵 New "not pushed" file state

The list only ever compared files against the manifest, which says nothing about whether a blob was uploaded — Track writes the hash immediately, so a file that had never left your disk showed as up to date. Blobs now get recorded as confirmed-in-storage when a Push uploads them (or finds them already there), a Pull downloads them, or Verify checks them, and the list gains a fourth not pushed state.

No network calls: the record lives under Library/UniLFS/ and is scoped per storage location, so repointing the project at another bucket does not inherit the old bucket's confirmations. Safe to delete — files fall back to not pushed until the next Push or Pull confirms them again.

🔐 A visible sign-in path

The sign-in button was buried in Project Settings, so an unconfigured project announced itself as a failed Push. UniLFS now asks once per editor session when a project tracks files but the provider is not ready, offering to sign in directly (Google Drive) or open the settings — silent when nothing is tracked, never shown in batch mode, and mutable per project. The UniLFS window shows the same thing as a banner with a direct sign-in button.

🐛 Fixes

  • Operations can no longer run twice. The window and Auto Pull/Push each guarded only themselves, so a manual Push racing an Auto Push meant whichever saved the manifest last silently discarded the other's entries. Push / Pull / Restore / Track / Untrack / Status now share a process-wide lock.
  • Project Settings layout — page padding and label width match built-in settings pages, Secret Access Key and Client Secret (project) no longer clip, wrapped help text is no longer cut to a single line, and the Google Drive account and folder rows line up with the fields above them.
  • Credentials are written on focus-out, not per keystroke. Saving user settings also rewrites the managed .gitignore block, so typing a secret did that once per typed character.
  • Connection test failures are shown as errors, and each provider lists what is still missing before Push/Pull can work.

📦 Install / upgrade

https://github.com/Plumvery/UniLFS.git#v0.3.0

Full changelog: v0.2.0...v0.3.0

v0.2.0 — Auto Push & CI verify gate

Choose a tag to compare

@Plumvery Plumvery released this 21 Jul 14:46

Automatic uploads and a CI safety net: the "forgot to push the blobs" failure mode is now caught on both ends.

✨ What's new

🔄 Auto Push

When tracked files have local changes that were never uploaded, UniLFS detects it — on focus changes, and in Automatic mode right after the asset is saved/imported — and asks or uploads in the background. Blobs are already in storage by the time you commit the manifest. Configure via Project Settings > UniLFS > Auto Push (Ask by default / Automatic / Off).

🤖 CI verify gate (no Unity needed)

  • Documentation~/ci/verify_manifest.py — Python-stdlib-only script that fails CI when a committed manifest references blobs missing from storage. Works for both S3-compatible and Google Drive providers; also usable as a client-side pre-push hook. No Unity license required.
  • UniLFS.Editor.UniLfsCli.Verify — the same gate as a Unity batch-mode entry point.

See Documentation~/ci.md for GitHub Actions examples and why uploads themselves must happen client-side.

📦 Install / upgrade

https://github.com/Plumvery/UniLFS.git#v0.2.0

Full changelog: v0.1.0...v0.2.0

v0.1.0 — Initial release

Choose a tag to compare

@Plumvery Plumvery released this 21 Jul 14:46

The first release of UniLFS — store large Unity assets in your own external storage (Cloudflare R2 / any S3-compatible service / Google Drive) instead of Git LFS.

✨ Highlights

  • Track / Untrack large files from the Project window context menu; the .gitignore managed block and the SHA-256 manifest (unilfs.manifest.json) are maintained automatically, and .meta files stay in git so GUIDs never break
  • S3-compatible provider (Cloudflare R2, Amazon S3, MinIO, Wasabi) with a dependency-free AWS Signature V4 implementation
  • Google Drive provider with OAuth loopback sign-in (PKCE) and resumable uploads
  • Content-addressed storage (objects/<aa>/<sha256>) with hash-verified downloads
  • Window > UniLFS management window, Project Settings > UniLFS configuration UI with per-user credential storage and connection test
  • Auto Pull on editor start / focus regain — no git hooks needed
  • CI entry points: UniLFS.Editor.UniLfsCli.Pull / Push / Status with env-var credentials

📦 Install

Window > Package Manager+Add package from git URL:

https://github.com/Plumvery/UniLFS.git#v0.1.0