@wildwinter/simple-vc-lib
v0.2.0
Published
An agnostic version control wrapper library for game development tools.
Maintainers
Readme
simple-vc-lib
simple-vc-lib is a multi-language library that provides an agnostic wrapper around common version control systems for tools. It lets your tools create, edit, and delete files without needing to know or care which version control system the user has in place.
I wrote this for game dev tooling, but it might be useful for your other projects.
import { writeTextFile, deleteFile } from './simpleVcLib.js';
// Write a file - checks out if needed, writes, adds to VC if new
const result = writeTextFile('/path/to/dialogue.json', JSON.stringify(data));
if (!result.success) {
console.error(result.message); // e.g. "File is locked by another user"
process.exit(1);
}
// Deleting - marks for deletion in VC if tracked
deleteFile('/path/to/old-dialogue.json');using SimpleVCLib;
// Write a file - checks out if needed, writes, adds to VC if new
var result = VCLib.WriteTextFile("/path/to/dialogue.json", jsonContent);
if (!result.Success) {
Console.WriteLine(result.Message); // e.g. "File is locked by another user"
return;
}
// Deleting
VCLib.DeleteFile("/path/to/old-dialogue.json");Contents
What it does
Game development tools often create, modify, or delete content files — dialogue JSON, audio files, configuration — while the project is under version control. If a file is checked in to Perforce or Plastic SCM it will typically be read-only until checked out, and new files need to be explicitly added.
simple-vc-lib handles all of that for you behind a single consistent API, so you can write one piece of code that works regardless of whether the user is on Git, Perforce, Plastic SCM, SVN, or no version control at all.
Source Code
The source can be found on Github, and is available under the MIT license.
Releases
- Javascript — available on npm as
@wildwinter/simple-vc-lib. Includes ESM and CommonJS builds with TypeScript definitions. - C# — available on NuGet as
wildwinter.SimpleVCLib. Targets .NET 8.
Both are cross-platform (macOS Arm64 and Windows x64).
Supported Version Control Systems
| System | CLI used | Detection |
|---|---|---|
| Git | git | .git folder/file walking up from the file path |
| Perforce (Helix Core) | p4 | p4 info command succeeds with a configured workspace |
| Plastic SCM / Unity Version Control | cm | .plastic folder walking up from the file path |
| SVN (Subversion) | svn | .svn folder walking up from the file path |
| Filesystem (no VC) | — | Fallback when nothing else is detected |
The library calls the relevant CLI under the hood. The appropriate CLI tool must be installed and on the system PATH.
NOTE: I haven't been able to test Plastic SCM fully yet. Please let me know if you have any issues with it.
Usage
Overview
- Use
writeTextFileorwriteBinaryFileto write a file. These all-in-one helpers check out or unlock the file if needed, write it, and add it to VC if it's new. Works whether or not the file already exists. If the file already exists and its content is unchanged, no VCS operations are performed and the file is not rewritten (passforceWrite: trueto override this). - Use
writeTextFilesto write a whole batch of files in one call, with a per-file outcome report — one refused file doesn't stop the rest. - If you need finer control, the steps are also available individually: call
prepareToWritebefore writing (checks out / unlocks the file, or no-ops if it doesn't exist yet), then write the file yourself, then callfinishedWriteafterwards (adds the file to VC if it's new). - Call
deleteFileordeleteFolderto remove files. Tracked files will be marked for deletion in the VC system; untracked files are just deleted from disk. - Call
renameFileorrenameFolderto move or rename files and directories. Tracked items are moved within the VC system; untracked items are moved on disk. - Call
fileStatuswith a batch of paths to find out, per file, whether it's tracked, writable, locked by someone else, checked out by you, or out of date — see Status Reads. Useful for editor UI ("this file is checked out by Bob") without spawning one CLI call per file.
You don't need to tell the library which VC system is in use — it detects this automatically. See VC Detection below.
Operations
writeTextFile(filePath, content, encoding, forceWrite)
An all-in-one helper that calls prepareToWrite, writes content as text, then calls finishedWrite. Works whether or not the file already exists.
encodingdefaults to UTF-8 (without BOM).- If the file already exists and its content matches
content, no VCS operations are performed and the file is not rewritten. This avoids unnecessary checkouts and dirty-file noise in your VC system. - Set
forceWritetotrueto bypass the content check and always write (default:false). - Returns the result from whichever step failed, or the result of
finishedWriteon success.
writeBinaryFile(filePath, data, forceWrite)
An all-in-one helper that calls prepareToWrite, writes data as raw bytes, then calls finishedWrite. Works whether or not the file already exists.
- If the file already exists and its content matches
data, no VCS operations are performed and the file is not rewritten. - Set
forceWritetotrueto bypass the content check and always write (default:false). - Returns the result from whichever step failed, or the result of
finishedWriteon success.
writeTextFiles(files, encoding)
Writes a batch of { filePath, content } entries through VC in one call, creating parent directories as needed. Each file goes through the same pipeline as writeTextFile (checkout if needed → write → add if new, with the unchanged-content short-circuit).
- Returns
{ success, results }whereresultsholds one outcome per file (filePath,success,status,message). - A refused file (e.g. locked by another user) does not stop the rest of the batch — it comes back in
resultswith its reason, so a tool can report exactly which files failed and why. successon the batch istrueonly when every file succeeded.
prepareToWrite(filePath)
Prepares a file path for writing. Use this when you need to write the file yourself rather than via writeTextFile / writeBinaryFile.
- If the file does not yet exist: succeeds immediately (ready to create).
- If the file exists and is writable: succeeds immediately.
- If the file exists and is read-only: checks it out (Perforce, Plastic SCM, SVN) or removes the read-only attribute (Git, filesystem).
On failure, the status field indicates the reason: locked means the file is exclusively held by another user; outOfDate means the local copy is behind the depot and needs syncing first.
finishedWrite(filePath)
Notifies the library that a file has been written. Use this after writing the file yourself following a prepareToWrite call.
- If the file is newly created (not yet in VC): adds it (
git add,p4 add,cm add,svn add). - If the file was already tracked: no-op.
deleteFile(filePath)
Deletes a file, scheduling it for deletion in VC if it is tracked.
deleteFolder(folderPath)
Deletes a folder and all its contents. Tracked files are scheduled for VC deletion; untracked files are deleted from disk.
renameFile(oldPath, newPath)
Renames (moves) a file, informing VC of the change if the file is tracked (git mv, p4 move, cm mv, svn move). Untracked files are renamed on disk only. No-op if the source does not exist.
renameFolder(oldPath, newPath)
Renames (moves) a folder, informing VC of the change for all tracked contents. Untracked content is moved on disk. No-op if the source does not exist.
Status Reads
fileStatus(filePaths) answers "what state are these files in?" for a whole batch of paths at once — the read-side counterpart to the write operations above. It's designed for tool UI: greying out files locked by someone else, showing who has them, flagging stale copies.
import { fileStatus } from '@wildwinter/simple-vc-lib';
for (const st of fileStatus(scenePaths)) {
if (st.lockedBy) console.log(`${st.filePath} is checked out by ${st.lockedBy.join(', ')}`);
}foreach (var st in VCLib.FileStatus(scenePaths)) {
if (st.LockedBy is not null)
Console.WriteLine($"{st.FilePath} is checked out by {string.Join(", ", st.LockedBy)}");
}Each result has:
| Field | Type | Description |
|---|---|---|
| filePath | string | Absolute path of the file |
| system | string | git, perforce, plastic, svn, or filesystem |
| writable | bool | Writable on disk right now (the read-only bit — under lock-based workflows this is the cheap "can I edit this?" signal; a file not on disk yet counts as writable) |
| tracked | bool? | Known to the VC system (tracked / in the depot). Absent when the provider can't say |
| openedByMe | bool? | Opened / checked out / locked by the current user |
| lockedBy | string[]? | Who else has it open or locked, e.g. bob@bob-ws |
| outOfDate | bool? | A newer revision exists on the server |
| dirty | bool? | Has pending local VC changes — a tracked file that is modified / staged / opened / added / deleted but not yet committed. Untracked files are not dirty (they surface via tracked: false). The cheap, local notion: it does not detect a file edited outside VC (e.g. a Perforce file made writable and changed without being opened). Absent when the provider can't say |
Local by default, server reads opt-in. fileStatus accepts a remote option:
fileStatus(paths); // local where possible (fast)
fileStatus(paths, { remote: true }); // also fetch server lockedBy / outOfDateVCLib.FileStatus(paths); // local where possible
VCLib.FileStatus(paths, remote: true); // also fetch server lockedBy / outOfDatelockedBy and outOfDate need a server round-trip for SVN and Plastic, so they are
only fetched when remote: true. Perforce and git-LFS already carry that data in the
one call they must make, so they report it either way (the flag is a no-op for them).
Calls are batched — paths are grouped by provider and repository, so a whole project's worth of files costs a spawn or two, not one per file:
| System | How | Depth |
|---|---|---|
| Perforce | ONE p4 -ztag fstat for the whole batch | Full, always: tracked, dirty (opened in a changelist), checkout/lock owners, out-of-date |
| Git | One git status --porcelain -z + one git lfs locks --verify --json per repository | Full: tracked, dirty, plus lock ownership when git-lfs locking is in use (git itself has no locks) |
| Plastic SCM | ONE cm status --machinereadable --all --ignored; with remote, plus ONE cm fileinfo + cm whoami | Local: tracked, dirty. Remote: lockedBy / openedByMe / outOfDate |
| SVN | ONE svn status --xml -v; with remote, adds -u | Local: tracked, dirty. Remote: lockedBy / openedByMe / outOfDate. The writable bit still reflects svn:needs-lock workflows |
| Filesystem | — | Writable bit only |
Path matching is done on repo-relative paths internally, so symlinked locations (such as macOS /var → /private/var temp directories) report correctly.
Return Values
All operations return a result object with three fields:
| Field | Type | Description |
|---|---|---|
| success | bool | true if the operation succeeded |
| status | string/enum | ok, locked, outOfDate, or error |
| message | string | Human-readable detail, especially on failure |
locked and outOfDate are only produced by prepareToWrite, for VC systems that support exclusive locking or require syncing before editing.
Two exceptions to the single-result shape: writeTextFiles returns { success, results } with one such outcome per file (plus its filePath), and fileStatus returns the status records described under Status Reads.
Async API
Every operation has an async twin with the same name plus Async, returning a Promise (JS) / Task (C#) — so a slow VC command (a Perforce server round-trip, a heavy cm startup) never blocks the calling thread:
import { fileStatusAsync, writeTextFileAsync, deleteFileAsync } from 'simple-vc-lib';
const statuses = await fileStatusAsync(paths, { remote: true });
await writeTextFileAsync(path, content);var statuses = await VCLib.FileStatusAsync(paths, remote: true);
await VCLib.WriteTextFileAsync(path, content);The full set: fileStatusAsync, prepareToWriteAsync, finishedWriteAsync, writeTextFileAsync, writeBinaryFileAsync, writeTextFilesAsync, deleteFileAsync, deleteFolderAsync, renameFileAsync, renameFolderAsync.
Notes:
fileStatusAsyncruns providers concurrently — a project spanning several repos/working copies finishes in about the time of its slowest provider, not the sum.- Reads, writes, and (in JS) every operation use real non-blocking subprocess I/O. In C#, the delete/rename twins reuse the tested sync logic on a thread-pool thread (
Task.Run) rather than duplicating Perforce's intricate changelist handling — the subprocess wait parks a pooled thread. writeTextFilesAsyncis sequential, matching the sync version (VC checkout commands on one workspace aren't safe to run concurrently).
VC Detection
The library detects the active VC system automatically, in this order:
SIMPLE_VCenvironment variable — set this togit,perforce,plastic,svn, orfilesystemto skip auto-detection entirely..vcconfigfile — a JSON file placed anywhere in the directory tree above the file being operated on:{ "system": "perforce" }- Marker directories — the library walks up from the file's directory looking for
.git,.plastic, or.svn. - Perforce — runs
p4 infoto check whether a Perforce workspace is configured. - Filesystem fallback — if nothing is detected, the library operates on plain files with no VC interaction. Read-only files are still handled by removing the read-only attribute.
Detection results are cached by VCS root directory. After the first operation on a file inside a repo, subsequent operations on files in the same repo skip the directory walk entirely. Files outside any known VCS root (for example, writing to a temp directory) are detected independently and will use the filesystem fallback without affecting the cache.
Overriding VC Detection
If auto-detection is unreliable in your environment, you can also force a specific provider in code:
Javascript:
import { setProvider, clearProvider, GitProvider } from './simpleVcLib.js';
setProvider(new GitProvider()); // Force Git for all operations
// ...
clearProvider(); // Restore auto-detectionC#:
using SimpleVCLib;
VCLib.SetProvider(new GitProvider()); // Force Git for all operations
// ...
VCLib.ClearProvider(); // Restore auto-detectionAvailable provider classes: GitProvider, PerforceProvider, PlasticProvider, SvnProvider, FilesystemProvider.
Overriding Command Execution (for tests)
Every CLI call goes through a single command runner, and you can override it — the same pattern as setProvider. This lets tests feed canned CLI output to the providers, so logic like the Perforce fstat parsing is unit-testable on machines with no p4 installed (and no live workspace):
Javascript:
import { setCommandRunner, clearCommandRunner, setProvider, PerforceProvider, fileStatus } from '@wildwinter/simple-vc-lib';
setProvider(new PerforceProvider());
setCommandRunner((command, args) => ({
exitCode: 0,
output: '... depotFile //depot/a.txt\n... clientFile /ws/a.txt\n... headRev 7\n... haveRev 7',
error: '',
}));
const [st] = fileStatus(['/ws/a.txt']); // parsed from the canned transcript
clearCommandRunner(); // restore real executionC#:
VCLib.SetProvider(new PerforceProvider());
VCLib.SetCommandRunner((command, args) =>
new CommandResult(0, "... depotFile //depot/a.txt\n... clientFile /ws/a.txt\n... headRev 7\n... haveRev 7", ""));
var st = VCLib.FileStatus(["/ws/a.txt"])[0];
VCLib.ClearCommandRunner(); // restore real executionThe override is global static state, so tests that use it (or setProvider) should not run in parallel with tests doing real VC operations.
Javascript
Install via npm:
npm install @wildwinter/simple-vc-libOr download simpleVcLib.js (ESM) or simpleVcLib.cjs (CommonJS) from the GitHub releases area and add them directly to your project.
// ESM (npm)
import { writeTextFile, writeBinaryFile, writeTextFiles, fileStatus, prepareToWrite, finishedWrite, deleteFile, deleteFolder, renameFile, renameFolder } from '@wildwinter/simple-vc-lib';
// ESM (direct file)
import { writeTextFile, writeBinaryFile, writeTextFiles, fileStatus, prepareToWrite, finishedWrite, deleteFile, deleteFolder, renameFile, renameFolder } from './simpleVcLib.js';
// All-in-one helpers (checkout + write + add to VC)
const result = writeTextFile('/path/to/myfile.json', JSON.stringify(data), 'utf8');
if (!result.success) {
console.error(result.message); // e.g. "'myfile.json' is locked by another user"
process.exit(1);
}
const binResult = writeBinaryFile('/path/to/myfile.bin', buffer);
if (!binResult.success) {
console.error(binResult.message);
}
// Batch write — one call, per-file outcomes; a refused file doesn't stop the rest
const batch = writeTextFiles([
{ filePath: '/path/to/scenes/opening.json', content: openingJson },
{ filePath: '/path/to/scenes/finale.json', content: finaleJson },
]);
for (const r of batch.results.filter((r) => !r.success)) {
console.error(`${r.filePath}: ${r.message}`); // e.g. "locked by bob@bob-ws"
}
// Batched status — tracked / writable / locked-by / out-of-date for a whole set of files
for (const st of fileStatus(['/path/to/scenes/opening.json', '/path/to/scenes/finale.json'])) {
if (st.lockedBy) console.log(`${st.filePath} is checked out by ${st.lockedBy.join(', ')}`);
}
// Renaming
const renResult = renameFile('/path/to/old-name.json', '/path/to/new-name.json');
if (!renResult.success) {
console.error(renResult.message);
}
renameFolder('/path/to/old-folder', '/path/to/new-folder');
// Manual approach — if you need to write the file yourself
const prep = prepareToWrite('/path/to/myfile.json');
if (!prep.success) {
console.error(prep.message);
process.exit(1);
}
// ... write the file ...
const add = finishedWrite('/path/to/myfile.json');
if (!add.success) {
console.error(add.message);
}// CommonJS (npm)
const { writeTextFile, writeBinaryFile, prepareToWrite, finishedWrite } = require('@wildwinter/simple-vc-lib');
// CommonJS (direct file)
const { writeTextFile, writeBinaryFile, prepareToWrite, finishedWrite } = require('./simpleVcLib.cjs');C#
Install via NuGet:
dotnet add package wildwinter.SimpleVCLibOr download SimpleVCLib.dll from the GitHub releases area and add it to your project references directly.
using SimpleVCLib;
// All-in-one helpers (checkout + write + add to VC)
var result = VCLib.WriteTextFile("/path/to/myfile.json", jsonContent);
if (!result.Success)
Console.WriteLine(result.Message); // e.g. "'myfile.json' is locked by another user"
// With explicit encoding
var result2 = VCLib.WriteTextFile("/path/to/myfile.txt", text, System.Text.Encoding.Unicode);
if (!result2.Success)
Console.WriteLine(result2.Message);
var binResult = VCLib.WriteBinaryFile("/path/to/myfile.bin", data);
if (!binResult.Success)
Console.WriteLine(binResult.Message);
// Batch write — one call, per-file outcomes; a refused file doesn't stop the rest
var batch = VCLib.WriteTextFiles([
new VCFileWrite("/path/to/scenes/opening.json", openingJson),
new VCFileWrite("/path/to/scenes/finale.json", finaleJson),
]);
foreach (var r in batch.Results.Where(r => !r.Success))
Console.WriteLine($"{r.FilePath}: {r.Message}"); // e.g. "locked by bob@bob-ws"
// Batched status — tracked / writable / locked-by / out-of-date for a whole set of files
foreach (var st in VCLib.FileStatus(["/path/to/scenes/opening.json", "/path/to/scenes/finale.json"]))
if (st.LockedBy is not null)
Console.WriteLine($"{st.FilePath} is checked out by {string.Join(", ", st.LockedBy)}");
// Manual approach — if you need to write the file yourself
var prep = VCLib.PrepareToWrite("/path/to/myfile.json");
if (!prep.Success)
{
Console.WriteLine(prep.Message);
return;
}
// ... write the file ...
var add = VCLib.FinishedWrite("/path/to/myfile.json");
if (!add.Success)
Console.WriteLine(add.Message);
// Deleting a folder
var del = VCLib.DeleteFolder("/path/to/old-content/");
if (!del.Success)
Console.WriteLine(del.Message);
// Renaming
var ren = VCLib.RenameFile("/path/to/old-name.json", "/path/to/new-name.json");
if (!ren.Success)
Console.WriteLine(ren.Message);
VCLib.RenameFolder("/path/to/old-folder", "/path/to/new-folder");Contributors
- wildwinter - original author
License
MIT License
Copyright (c) 2026 Ian Thomas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.