npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@wildwinter/simple-vc-lib

v0.2.0

Published

An agnostic version control wrapper library for game development tools.

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 writeTextFile or writeBinaryFile to 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 (pass forceWrite: true to override this).
  • Use writeTextFiles to 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 prepareToWrite before writing (checks out / unlocks the file, or no-ops if it doesn't exist yet), then write the file yourself, then call finishedWrite afterwards (adds the file to VC if it's new).
  • Call deleteFile or deleteFolder to remove files. Tracked files will be marked for deletion in the VC system; untracked files are just deleted from disk.
  • Call renameFile or renameFolder to move or rename files and directories. Tracked items are moved within the VC system; untracked items are moved on disk.
  • Call fileStatus with 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.

  • encoding defaults 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 forceWrite to true to bypass the content check and always write (default: false).
  • Returns the result from whichever step failed, or the result of finishedWrite on 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 forceWrite to true to bypass the content check and always write (default: false).
  • Returns the result from whichever step failed, or the result of finishedWrite on 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 } where results holds 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 results with its reason, so a tool can report exactly which files failed and why.
  • success on the batch is true only 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 / outOfDate
VCLib.FileStatus(paths);                // local where possible
VCLib.FileStatus(paths, remote: true);  // also fetch server lockedBy / outOfDate

lockedBy 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:

  • fileStatusAsync runs 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.
  • writeTextFilesAsync is 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:

  1. SIMPLE_VC environment variable — set this to git, perforce, plastic, svn, or filesystem to skip auto-detection entirely.
  2. .vcconfig file — a JSON file placed anywhere in the directory tree above the file being operated on:
    { "system": "perforce" }
  3. Marker directories — the library walks up from the file's directory looking for .git, .plastic, or .svn.
  4. Perforce — runs p4 info to check whether a Perforce workspace is configured.
  5. 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-detection

C#:

using SimpleVCLib;

VCLib.SetProvider(new GitProvider()); // Force Git for all operations
// ...
VCLib.ClearProvider();                // Restore auto-detection

Available 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 execution

C#:

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 execution

The 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-lib

Or 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.SimpleVCLib

Or 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

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.