Avatar VCS
A Unity package for version-controlling VRChat avatar edits.
Background
Version control in Unity projects is usually done with Git. But many Unity assets are a poor fit for Git, and VRChat avatar edits in particular are hard to manage that way. So I decided to build a tool that lets you manage avatar edits visually.
What I Focused On
I kept functions as simple as I could so that they would be easy to test, and put care into a UI that makes avatar edits visible at a glance. The UI was then refined based on feedback from people using it.
Looking Back
This project made me feel just how hard testing is in Unity. Unity tests only run inside the Unity editor, which makes CI/CD awkward to set up. And unlike web development, the client holds a lot of state, which makes shipping backwards-compatible upgrades difficult. Upgrade paths are something to think about from the design stage onward.
Technical DetailsOpenClose
How the Assemblies Are Split
Most of what makes Unity code hard to test is logic stuck to the Editor API and to scenes. So I split code that depends on UnityEditor from code that doesn’t, at the assembly level.
flowchart TB
E["AvatarVcs.Editor / UI, recording, applying, history operations"]
C["AvatarVcs.Core / models, history, diffs, display logic"]
R["AvatarVcs.Runtime / marker component"]
E --> C
C --> R
E --> R
Core never calls a UnityEditor API, so it can be tested without a scene or the AssetDatabase. Everything that hurts when it breaks — diff computation, commit ID validation, updating a branch HEAD — lives in that layer. What is left in the Editor layer is only the part that actually touches Unity APIs.
Separating UI from State
Keeping state in the EditorWindow sharply narrows what can be tested, so state and transitions moved into AvatarVcsPresenter, leaving the window with nothing but drawing and forwarding input. The presenter depends on three ports only, with the Editor implementations behind each.
| Port | Role | Editor implementation |
|---|---|---|
IHistoryStore |
reading and writing commits, the index and settings | EditorHistoryStore |
IAvatarGateway |
operations on the avatar in the scene | EditorAvatarGateway |
IUserPrompt |
showing confirmation dialogs | EditorUserPrompt |
Branch switching and comparison-mode transitions can be checked in presenter tests. Because the dialog can be swapped out, a branch like “the user pressed No on the delete confirmation” can be written without opening the window.
Where and How Things Are Stored
Commits are stored as JSON under ProjectSettings rather than as Unity assets.
ProjectSettings/AvatarVcs/
├── guid-remapping.json
└── avatars/{avatarGuid}/
├── config.json branch name → HEAD commit ID
├── index.json list of commits
└── commits/{commitId}.json
Avatars are identified by a 32-character hex GUID. The GUID is held by a component attached to the avatar, so duplicating an avatar duplicates the GUID; OnValidate detects that and reissues one. Since GUIDs and commit IDs become file paths directly, their format is validated before any write to prevent path traversal.
What gets recorded is not the prefab itself, but the list of prefab GUIDs plus the changes layered on top of them (transforms, tag/active/layer, component field values, blend shapes, material slots, shader properties). The substance of an avatar edit is less “which outfit was added” than “what was tweaked after adding it”, and that was the part Git handled badly — so that is the part this tool stores in a structured form.
A content hash of each asset is recorded alongside the commit and compared against the current content at checkout time. That way, if an outfit has been updated in the meantime, the tool can warn instead of silently applying the commit.
Distribution and CI
Distribution takes the form of a VPM repository, with index.json at the repository root acting as the package index. Releases run through GitHub Actions, triggered by pushing a tag.
If the tag with its leading v stripped does not match the version in package.json at that tag, the run fails right there. Releasing from a tag that does not point at the tip of main would otherwise produce a zip whose name and contents disagree. Once validation passes, the workflow builds the zip, appends the version to index.json and writes it back to main, and creates a GitHub Release.
Tests run EditMode through game-ci/unity-test-runner on every push and pull request. Because the code is split into the Core and Editor assemblies, the half that needs no scene runs cheaply inside the same setup.