GregOrigin - SyncShield: Dynamic Safety Net, Sentinel & Validator for Git & Plastic SCM

Watch it in action.

Read the manual.

A functional OSS version is available on GitHub: https://github.com/gregorik/SyncShield-Core, please file Issues if appropriate. Fab hosts the fully featured & supported Pro version.

SyncShield is a lightweight, battle-tested UE5 plugin designed to eliminate source control friction and protect your team from lost work and locked-file conflicts. It natively integrates with Unreal's Editor to warn you before you make changes, and safely catches you if you try to overwrite someone else's work. This is a major upgrade and more ambitious refactoring of the earlier SafeSave plugin which remains fully functional in its own narrower scope.

Core Features:

🚫 Strict File Locking Protection

SyncShield natively intercepts your "Save All" commands. Before any data is written, it rapidly probes your source control provider (Git, Plastic SCM, Perforce) for checkout status. If any package is locked by a teammate (IsCheckedOutOther), SyncShield blocks the save for those specific files with an explicit warning, while automatically issuing checkouts and safely saving the rest.

🔔 Preemptive "Dropbox-Style" Alerts

Don't wait until you save to find out a file is locked. SyncShield hooks directly into the Unreal Asset Editor. The moment you open a Blueprint, Material, or Data Asset that is locked by another developer, you instantly get a prominent Toast Notification warning you not to edit it.

🛠️ Streamlined Editor Toolbar

Stop fumbling with external CLI windows or hidden context menus. SyncShield adds a dedicated, dynamic status widget right to the Level Editor Toolbar.

*Live Status Updates: See your Branch, pending changes, and unsaved asset counts at a glance.

*One-Click Actions: Save All, Submit Content, or Refresh Status.

*Native Git Integration: Auto-Fetch (configurable interval), Pull (Rebase), and Push directly from the toolbar.

*Native Plastic SCM Integration: One-click Workspace Update.

⚔️ Conflict Sentinel: Stop merge conflicts before they happen. SyncShield quietly tracks your locally dirty assets and runs background checks against your remote Git branch. If a teammate pushes a change to a file you are currently editing, you get an instant toast warning you of the impending collision.

🌿 Safe Branch Shifter: Switch Git branches without playing Russian Roulette with the Unreal Editor. SyncShield's "Safe Switch" pipeline protects your unsaved data, forcefully closes vulnerable asset editors, performs a clean checkout, and lets the Asset Registry hot-reload safely without crashing.

⏪ Local Save History & Time Travel: SyncShield quietly takes lightweight, localized snapshots of your assets every time you save. Broke a Blueprint? Click "Restore Latest Snapshot" to instantly revert the active asset to its last known good state—without needing to pull from remote source control.

🛑 Pre-Save Data Validation: Automatically run custom or engine-level validation checks before assets are committed to disk. Prevent broken references, bad naming conventions, or uncompiled Blueprints from ever reaching your repository.

📂 Advanced Save Profiles: Stop saving everything just to be safe. Use precision save commands:

Save Blueprints Only

Save Current Level Only

Save Recently Touched (Time-windowed)

0.6 update added:

Added

Services Architecture

  • Added Local Save History Service (FSyncShieldHistoryService): automatic pre-save snapshots captured to a per-project history directory. Snapshots are deduplicated by SHA-256 hash so only genuine content changes are stored. Configurable via Max Snapshots Per Asset (default 10); excess snapshots are pruned oldest-first.
  • Added Pre-Save Validation Service (FSyncShieldValidationService): runs on every save and validates asset naming against a configurable regex pattern (AssetNamingPattern, default ^[A-Z][A-Za-z0-9_]*$), checks texture dimensions against Max Recommended Texture Dimension (default 8192), and optionally runs the engine's UObject::IsDataValid(). Issues are surfaced as editor message log entries. When Block SyncShield Save Actions On Errors is enabled, SyncShield's own save profiles refuse to save packages with blocking validation errors.
  • Added Save Profile Service (FSyncShieldSaveProfileService): provides four scoped save profiles — Save All, Save Blueprints, Save Current Level, and Save Recently Touched — each of which runs pre-save validation before writing. The "Recently Touched" profile uses a configurable time window (RecentlyTouchedSaveWindowMinutes, default 15) to save only packages the user has recently modified.
  • Added Restore Latest Snapshot: toolbar menu action to revert a dirty package to its most recent local history snapshot, with confirmation dialog and success/failure toast.

Git LFS Lock Management

  • Added Auto-unlock after Push: after a successful git push, SyncShield automatically queries git lfs locks --local and releases all locally-held file locks.
  • Added Auto-unlock after Submit Content: the "Submit Content" check-in action now releases LFS locks after a successful commit.
  • Added Unlock LFS Files menu action: manually release all locally-held Git LFS file locks via a toolbar dropdown entry, with a confirmation dialog listing locked files.
  • Added lockable attribute to .gitattributes for *.uasset and *.umap, enabling the full Git LFS lock/unlock workflow where tracked files are read-only until explicitly locked.

Conflict Sentinel and Branch Shifter

  • Added Conflict Sentinel: detects files that are modified locally but also changed on the remote, raising preemptive conflict warnings before a pull.
  • Added Safe Branch Shifter: a toolbar submenu that lists local and remote branches, stashes unsaved work, switches branches, and pops the stash on arrival. Includes guardrails for dirty working trees and diverged states.

Editor Automation Showcase Commands

  • Added SyncShield.Demo.ShowcaseConflictSentinel console command: creates a synthetic conflicting state and demonstrates the Conflict Sentinel alert.
  • Added SyncShield.Demo.ShowcaseBranchShifter console command: creates temporary branches and walks through a Safe Branch Shift cycle.
  • Added SyncShield.Demo.ShowcaseAllCommands console command: sequentially runs all showcase demonstrations.

Toolbar Enhancements

  • Added Quick Commit action: stage all changes and commit from a single dialog. Warning text now clearly states "This will stage ALL changes (including untracked files) and commit."
  • Added Git Stash and Git Stash Pop actions.
  • Added Git Merge Abort and Git Rebase Abort conflict resolution actions.
  • Added Save Blueprints, Save Current Level, and Save Recently Touched as separate save profile menu entries.
  • Added Validate Dirty Assets menu action to manually trigger pre-save validation on all currently dirty packages.
  • Added LFS lock count (Locked N) and locked-file list in toolbar tooltip when Git LFS locks are detected.
  • Added bGitattributesConfigured status field tracking whether the repository .gitattributes has the lockable attribute.

Settings

  • Added bEnableLocalSaveHistory (default true) — master toggle for the local save history service.
  • Added MaxLocalHistorySnapshotsPerAsset (default 10) — cap on retained snapshots per asset.
  • Added bEnablePreSaveValidation (default true) — master toggle for the pre-save validation service.
  • Added bRunObjectDataValidation (default false) — opt-in to run UObject::IsDataValid() during validation.
  • Added bBlockSyncShieldActionsOnValidationErrors (default true) — prevent SyncShield save profiles from writing packages that have blocking validation errors.
  • Added AssetNamingPattern (default ^[A-Z][A-Za-z0-9_]*$) — regex pattern for asset name validation.
  • Added MaxRecommendedTextureDimension (default 8192) — texture size warning threshold.
  • Added RecentlyTouchedSaveWindowMinutes (default 15) — time window for the "Recently Touched" save profile.

Automation Tests

  • Added SyncShield.Editor.Services.ValidationNamingPattern — tests default naming regex against valid/invalid names and custom patterns.
  • Added SyncShield.Editor.Services.ValidationSettings — verifies all default setting values match expected defaults.
  • Added SyncShield.Editor.Services.HistoryPrune — creates 25 fake snapshots, prunes to MaxSnapshots, verifies newest survive.
  • Added SyncShield.Editor.Services.RecentlyTouchedWindow — tests the recently-touched time-window comparison logic.
  • Added SyncShield.Editor.Services.LfsUnlockGating — tests LFS tooltip display with/without locks and LFS detection.
  • Full suite now at 12 tests, all passing on UE 5.7 and UE 5.6.

Changed

  • Reworked ExecuteGitPush() from a one-liner RunGitCommandAsync("push") to a multi-step async flow that checks for LFS locks before push and auto-unlocks after success.
  • Reworked ExecuteCheckIn() to release LFS locks after a successful source control check-in when locks were held beforehand.
  • Separated stdout/stderr handling in RunCommandWithTimeout(): stderr is now only populated when the exit code is non-zero, matching FMonitoredProcess behavior where stdout and stderr are merged.
  • Updated .uplugin description to be more descriptive for Fab listing.
  • Updated .uplugin URLs to use bare domain format (www.gregorigin.com) for Fab compatibility.
  • Updated FilterPlugin.ini with detailed documentation comments explaining each included path and why Binaries/ and Intermediate/ are excluded.

Fixed

  • Fixed persistent LFS lock icons: locks acquired during SyncShield's save workflow are now released after push and check-in rather than persisting indefinitely.
  • Fixed the "Check Out Assets" dialog appearing with a greyed-out "Check Out Selected" button when using Git without LFS locking. SyncShield now detects when the source control provider does not support checkout, clears the read-only flag on affected files, and bypasses the checkout prompt entirely.

0.7 update, 2026-08-30 :globe_with_meridians:

The plugin now fully supports UE 5.5–5.8.2, and the toolbar widget was modernized from scratch and streamlined.

:brick: Changed: the toolbar widget decomposed

SSyncShieldToolbar.cpp shrank from 2,984 to 1,702 lines; seven layers moved out.
Every layer is now reachable without constructing a Slate widget.

Layer Responsibility
:bar_chart: SyncShieldSourceControlStatus.h Status model and provider enum, at namespace scope
:high_voltage: SyncShieldProcess Process launching and executable resolution
:shuffle_tracks_button: SyncShieldGitProbe Git status probing and all git output parsing
:dna: SyncShieldPlasticProbe Plastic workspace probing and status parsing
:artist_palette: SyncShieldStatusPresenter Pure presentation: icon, label, colour, tooltip, summary
:thread: SyncShieldAsyncRunner RunForOwner — the one place owning async lifetime discipline
:control_knobs: SyncShieldCommands Source control operations as pure, testable functions

:rocket: Added

  • Git Executable Path and Plastic CLI Path settings. A GUI-launched editor on macOS and
    Linux does not inherit the login shell PATH, so Homebrew and /usr/local installs were
    invisible and reported as a missing client.

:recycling_symbol: Changed

  • Status change notifications are keyed on actual state transitions rather than on the toolbar
    label. Because the label embeds the unsaved-asset count, a toast fired on every edit.

  • The asset naming regex is compiled once per settings change instead of once per asset, and a
    structurally invalid pattern disables name validation with a single warning rather than
    flagging every asset in the project.

  • Session tracking maps for snapshot hashes, dirty timestamps, and notification throttling are
    bounded.

  • Removed the deprecated EditorStyle module dependency.

  • FSyncShieldStyle is now actually used (it registered a brush nothing consumed), no longer
    dereferences a possibly-null plugin descriptor, and no longer forces a renderer texture reload
    at editor startup.

  • Async lifetime discipline consolidated. Seven dispatch sites each hand-rolled the
    same trio — a weak self pointer, work on the pool, and a continuation that re-pins and
    bails if the widget is gone. RunForOwner owns that once; because it confirms the owner
    is alive on the game thread immediately before invoking, continuations capture this
    directly, which removes the boilerplate rather than relocating it.

  • Host targets track the engine. DefaultBuildSettings and IncludeOrderVersion moved
    from pinned V6/Unreal5_7 to Latest, so one tree configures on 5.5 through 5.8.

  • Test accessor surface cut from 26 static methods to 16 — the layers that moved out
    no longer need a friend relationship with the widget.

:test_tube: Tests

  • Suite expanded from 24 to 28, passing on UE 5.5, 5.6, 5.7 and 5.8.
  • :new_button: Presentation.WidgetFree — asserts label precedence and degraded status with no SNew,
    no accessor and no friend declaration.
  • :new_button: Async.RunForOwner and Async.RunForOwnerDeadOwner — the continuation arrives on the
    game thread with its result intact, and a dead owner suppresses it entirely.
  • :new_button: Commands.GitStashRoundTrip — drives a real stash and pop against a scratch repository.
  • Added coverage for process launch failure and output capture, executable path overrides, demo
    command gating, push/pull gating, LFS lock parsing and detection, Conflict Sentinel path
    matching, level scope, snapshot capture and deduplication, branch checkout argument
    construction, and status toast keying.

:wrench: Fixed: defects found while rebuilding the foundation

  • RunForOwner did not compile as drafted. The outer lambda captured Work and
    OnGameThread by copy then MoveTemp-ed them; without mutable those captures are const —
    static_assert failed: 'MoveTemp called on a const object'.
  • The runner’s own test suppressed its own continuation. The owner widget was a local in
    RunTest, destroyed as soon as the latent command was queued, so RunForOwner correctly
    suppressed the callback and it read as “continuation never arrived”. The owner now lives in
    shared state the latent command holds.
  • Duplicated helpers broke the unity build. The module compiles as a unity blob, so a
    helper copied into two files’ anonymous namespaces is a redefinition (C2084). Each
    extracted file uses a named private namespace.

:white_check_mark: Verified

Check Result
Full suite, UE 5.5 / 5.6 / 5.7 / 5.8 28 tests, 0 failed
RunUAT BuildPlugin, all four engines BUILD SUCCESSFUL
Packaged plugin in a blueprint-only project (no Source) 28 tests, 0 failed, all four engines
Shipping receipt excludes the editor module clean — and proven to flag a synthetic leak

:warning: Current limitations (WIP)

  • :straight_ruler: The test fixtures are MAX_PATH sensitive. The fixtures create scratch git
    repositories under the project, and one git object path adds ~97 characters. From a
    131-character project path, three git tests fail with Filename too long; from a short path
    all 28 pass. This affects the automation fixtures, not plugin runtime.
  • :triangular_ruler: The widget is 1,702 lines against a 800-line target. All seven planned extractions
    landed; what remains is genuinely view code, led by BuildMenu (201) and
    StartSourceControlStatusUpdate (98). Closing the gap needs a further round.

:collision: Fixed — crashes and memory safety

  • Use-after-free when a source control command times out. RunCommandWithTimeout()
    declared its output buffer after the FMonitoredProcess that writes to it. Cancel()
    only raises a flag, so the monitoring thread was still delivering output while the buffer
    was being destroyed. The buffer now outlives the process and is guarded by a lock.
  • Assertion when a command times out. FMonitoredProcess::GetReturnCode() asserts unless
    the process has stopped, but Cancel() returns before the monitoring thread clears that
    state. The timeout path now waits for the process to actually stop.
  • Slate widget could be destroyed on a worker thread. Every asynchronous git/Plastic path
    pinned the toolbar widget for the duration of the command, so the final reference could be
    released on the thread pool. The process and status helpers are now static, and only a weak
    pointer crosses the thread boundary.
  • Toolbar entry outlived the module. The toolbar widget was registered without a
    FToolMenuOwnerScoped, so UnregisterOwner() on shutdown could not remove it and the
    surviving widget kept calling into an unloaded module.

:shuffle_tracks_button: Fixed — source control correctness

  • A missing git/cm executable was reported as a healthy repository. Launch() failures
    were ignored, and FMonitoredProcess defaults its return code to 0, so the plugin reported
    bClientAvailable and bRepo with an empty repository root and ran every later command in
    an empty working directory.
  • The last line of command output was dropped when it did not end in a newline.
  • Checkout requests passed package names where filenames were required. Providers resolve
    the FString overload as file paths, so /Game/Foo/Bar pointed at a nonexistent file and the
    checkout silently did nothing. Save profiles now pass packages.
  • A cold source control state cache defeated the lock check. EStateCacheUsage::Use returns
    nothing before a status query, making every file look neither locked nor checkout-able. Save
    profiles now refresh status first.
  • Conflict Sentinel matched on asset name only, so same-named assets in different folders
    were treated as the same file. Matching is now by repository-relative path. It also no longer
    requires Unreal’s own source control provider to be enabled, and re-arms when the incoming
    change set moves instead of warning once per session.
  • Submit released every Git LFS lock even when cancelled. Locks are now released only after
    the check-in reports success.
  • Untracked files no longer block Push and Pull. They do not block either operation in git,
    but they left both actions permanently disabled in projects without a complete .gitignore.
  • Git LFS locks are read from --json. The human-readable listing is space-aligned, so the
    previous tab split corrupted any path containing a space. LFS detection now reads
    filter=lfs from .gitattributes rather than git lfs env, which only reports whether
    git-lfs is installed on the machine.

:floppy_disk: Fixed — data safety

  • Safe Branch Shifter left the editor out of sync with disk. It closed asset editors but
    never reloaded packages, so the editor kept the previous branch’s content in memory and the
    next save wrote it back over the newly checked-out files. Clean project packages are now
    reloaded after a successful switch.
  • Switching to a remote branch detached HEAD. git checkout origin/x now resolves to the
    matching local branch, or creates a tracking branch.
  • The auto-stash is now restored on arrival, which the 1.2.0 notes described but the code
    never did.
  • Restore Latest Snapshot destroyed the current version with no way back. It now snapshots
    the file it is about to overwrite, checks the file out where the provider supports it, and
    reports success when the file was restored but the in-editor reload failed (previously it
    reported failure after having already changed the file on disk).
  • Save Current Level skipped World Partition external actors and objects, silently dropping
    actor edits. It now includes __ExternalActors__/__ExternalObjects__ packages for the
    current level.
  • Snapshot capture failures are logged. A path exceeding the Windows limit previously failed
    silently, leaving the history looking healthy while capturing nothing.

:package: Fixed — configuration and packaging

  • Per-user settings were written to source-controlled project config. USyncShieldSettings
    combined EditorPerProjectUserSettings with defaultconfig, so toggling Auto Fetch produced a
    diff in Config/DefaultEditorPerProjectUserSettings.ini for the whole team. The
    defaultconfig specifier has been removed; settings now save per user.
  • Demo commands are opt-in. SyncShield.StressTest and the SyncShield.Demo.* commands
    create placeholder assets under /Game and spawn actors into the open level, and were
    registered unconditionally. They now require Enable Demo Content Commands (default off).
  • Quick Commit stages only the project folder. git add -A from the repository root swept in
    unrelated work when the project lives inside a larger repository. A commit message ending in a
    backslash no longer corrupts the command line.

I have updated the Demo Guide page: