This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Ignition Git Module — a Java module for the Inductive Automation Ignition SCADA platform (8.3.0+) that embeds a Git client into the Ignition Designer. It supports commit, push, fetch (without merge), pull (with merge-conflict resolution), revert, branch management, snapshotting gateway-side resources (tags/themes/images) into the project, and both remote (clone) and local-only repository initialization — all from the Designer's dockable panels and status bar. It also versions the gateway data directory (config-as-code) in a separate repo, surfaced through a dedicated React gateway web page (Platform → System → "Versioning") — config changes are auto-committed as they happen; the page provides history/restore. Originally built by AXONE-IO, maintained by Operametrix.
./gradlew build # produces build/Git.modl (also builds the web-ui React bundle)
./gradlew :web-ui:webpack # build only the gateway web page bundleThe :web-ui subproject uses the com.github.node-gradle.node plugin to download Node 18 and run yarn/webpack; the first build needs network access (IA Nexus node-packages registry + nodejs.org). To auto-fix ESLint/Prettier formatting (the webpack build fails on Prettier violations), run node_modules/.bin/prettier --write "src/**/*.{ts,tsx}" from web-ui/.
No automated tests. Testing is manual: install the .modl on an Ignition gateway and exercise the Designer UI and the gateway Versioning page.
Gradle multi-module project following the Ignition Module SDK pattern:
common (scope: DG) — RPC interface + abstract delegation base
designer (scope: D) — Designer UI: dockable panels, popups, status bar
gateway (scope: G) — all git operations + persistence + config-as-code REST routes
web-ui (no scope) — React gateway page (config-as-code), bundled into the gateway jar
Scopes: D = Designer, G = Gateway. The Vision client scope is unused — there is no system.git.* script module on Vision clients. The root build.gradle.kts (io.ia.sdk.modl plugin) assembles the .modl.
Hooks are the per-scope entry points (Ignition setup/startup/shutdown):
DesignerHook— builds the status bar, the dockable Commit/History panels, and a user-verification timer; talks to the gateway viaGatewayConnection.getRpcInterface(...). IfisProjectRegistered()is false it shows a minimal "Configure" + user-button bar so credentials can be added before init; after init viaInitRepoPopupit callsreinitializeAfterSetup()to build the full bar. A 1-secondpanelVisibilityTimerre-shows the Commit/History panels across workspace switches (checks hidden / null-in-DockingManager /!isDisplayable()). Exposes a staticinstanceforGitActionManagercallbacks.GatewayHook— registers the six resource types and starts theirNamedResourceHandlers, runs the one-time legacy SimpleORM→resource importer, and registers the gateway RPC implementation (getRpcImplementation()). Also wires the config-as-code feature: registers the React page in theNavigationModel(Platform → System → "Versioning"), overridesgetMountPathAlias()(git-config) /getMountedResourceFolder()(mounted), and mounts the REST routes inmountRouteHandlers(RouteGroup).
RPC pattern (8.3 module RPC): GitScriptInterface (common) is the contract, annotated @RpcInterface(packageId="com.operametrix.ignition.git") and exposing a shared SERIALIZER. The serializer is a custom ProtoRpcSerializer.newBuilder() instance — DEFAULT_INSTANCE has no Dataset support, so it registers a Java-serialization BinaryAdapter for Dataset/BasicDataset (ObjectSerializers.forUnsafeObject); without it every Dataset-returning RPC round-trips empty with no error. AbstractScriptModule (common) is a plain abstract base that delegates each interface method to a …Impl abstract method, supplied by GatewayScriptModule (gateway) — which must implements GitScriptInterface directly (8.3's RpcDelegate discovers @RpcInterface only on the concrete class's direct interfaces, no superclass walk). The gateway registers it via GatewayHook.getRpcImplementation() → GatewayRpcImplementation.of(SERIALIZER, scriptModule); the Designer obtains a proxy via GatewayConnection.getRpcInterface(SERIALIZER, "com.operametrix.ignition.git", GitScriptInterface.class).
Designer project refresh: after any gateway-side operation that mutates the Ignition project (pull, checkout, init, snapshot), the Designer must call GitBaseAction.pullProjectFromGateway(). Under 8.3's resource model this (a) discards stale local edits per ChangeOperation via typed DesignableProject.discardChanges(ResourcePath) — so a deliberate checkout doesn't open the Resolve-Conflicts dialog (the gateway is authoritative; uncommitted work is preserved by gateway-side git stash/restore) — then (b) reflectively calls the public IgnitionDesigner.updateProject(), the 8.3 successor to the removed private pullAndResolve(). closeAllEditorTabs() runs first and looks up TabbedResourceWorkspace.close(common.resourcecollection.ResourcePath, boolean) (the 8.3 resource overhaul moved ResourcePath from common.project.resource.* to common.resourcecollection.*). Without this refresh the gateway changes (via GitProjectManager.importProject()) won't show in the Designer.
Gateway data-directory versioning (config-as-code) — a separate git repo from the per-project repos, rooted at the data directory itself (<dataDir>/.git, via GitManager.getDataFolderPath()). It tracks gateway config (primarily <dataDir>/config/) using a .gitignore based on IA's version-control-guide template plus projects/ excluded — so the per-project repos under projects/ are untouched (no nesting/submodules). Orchestrated by DataDirGitManager (gateway), a thin static layer over the working-dir-agnostic GitManager primitives:
isInitialized()=<dataDir>/.gitexists (no persistence record; repo state lives in.git).initRepois explicit (never auto-run at startup) —git init+ write.gitignore+ baseline commit.getStatus()is a plain porcelain listing scoped toconfig/.gitignore(the project-resourcehasActor/getActor/filterMetadataOnlyChangeshelpers must not be applied to config files); JSON key-ordering noise is suppressed viaGitManager.filterJsonOrderingChanges.- Auto-commit:
ConfigAutoCommitter, aResourceCollectionListenerregistered viaConfigurationManager.addListenerinGatewayHook.startup— the manager-level listener is the only live notification surface; per-resourceResourceListeners ongetConfigCollection()are never notified (that call builds a snapshot per invocation). Events within a 2s quiesce window coalesce, so one gateway operation plus its follow-up resources (e.g. a device's System tag definitions) lands as one commit viacommitAllIfDirty(no-op on a clean tree, so the post-restore scan doesn't double-commit; gitignoredlocalcollection skipped). Manager events carry no resource detail, so the message (changed files grouped into resource dirs) comes from the git status; the author is the gateway system name.commitLeftovers()runs 10s after startup to sweep changes made while the gateway/module was offline (the one case the listener can't see — even IaC disk edits go through Scan File System, which fires the listener). There is no manual commit: no/commitroute, and the page is history + restore only (no Uncommitted Changes panel). - Restore =
GitManager.restoreTree(path, hash)(resets the working tree/index to exactly the target tree — overwrites dirty tracked files, removes tracked files added since, andgit cleans untracked non-ignored files so uncommitted changes are discarded; keeps HEAD on the branch — unlike the detachingcheckoutCommit) then a forward "Restore config to " commit, thenapplyConfigToRunningGateway()=GatewayContext.getConfigurationManager().requestScan().join(), which applies the on-disk config to the running gateway with no restart. A single staticDATA_DIR_LOCKserializes commit/restore/status against concurrent gateway config writes. - Remote (manual sync only):
GitConfigRemoteRecordis a gateway-level singleton (URI, branch — defaultmain, credential FK).DataDirGitManager.push()pushesHEAD:refs/heads/<branch>and advances the localrefs/remotes/origin/<branch>tracking ref; no auto-push — only the header's Remote Sync button (Refreshicon).GET /remotereturnsahead=DataDirGitManager.aheadCount()(commits on HEAD not reachable from the tracking ref); the header shows a bold "N not synced" (var(--warning)) / "✓ Up to date" indicator and the Sync button is primary only when diverged (the query polls 15s so the count tracks auto-commits). The History list badges only the two branch-tip commits — Local (HEAD,altchip) / Remote (tracking ref, neutralinfochip) — like git ref pointers. - Config UI = a lateral drawer (
RemoteSyncheader +ConfigDrawer), styled like the platform Redundancy page: a primary "Configure Versioning" button (trailingSettingsGwcog) opens a right-anchoredDrawer+DrawerTemplate(theme=GREY,size=SMALL) whose body isForm+FormControlInput+Cardsections (react-hook-form;react-hook-formis a shared webpack external). The remote's secret is entered inline (no credential dropdown, no popup): an Embedded/ReferencedRadio, then either the typed secret or provider/secretTextAutocompletes (disabled + red "No Providers Exist" when none). No credential name is asked —handleSaveRemotecreates/updates one dedicated credential record in place (auto-namedConfig repository (<host>)) from the inline secret and links it;handleGetRemotereturns mode/username/referenced-provider/secret for prefill (never the embedded secret);handleTestRemotetests with the inline secret viaGitManager.setAuthenticationRaw(referenced secrets resolved throughSecret.create), falling back to the saved credential when editing without re-entering. A Danger Zone card (red header) holds Delete versioning →POST /deinit→DataDirGitManager.deleteRepo()(removes<dataDir>/.git+.gitignore+ remote record, keeps credentials). WebUI confirm modals must usemodalConfig.confirmationText(NOTdescriptionText, which only renders fortype:"primary");FormControlInput'sdisabledonly greys the label — disable the input viaotherProps={{disabled}}. - Caveat: inline encrypted secrets in
config/are tied to the gateway's encryption key (keystore/certs are gitignored) — same-gateway restore only; the web page's Restore dialog warns about this and about live-resource reconfiguration.
The page is React (8.3 gateway pages are React-only via NavigationModel; Wicket config pages are gone, and there is no module-accessible API for a global banner-on-all-pages or a dynamic nav badge — verified against gateway-api-8.3.6). It talks to the gateway via REST routes mounted in GatewayHook.mountRouteHandlers (NOT the RPC interface), under /data/git-config/…: GET /status|/history|/commit-files|/file-diff|/remote|/secret-providers, POST /restore|/init|/deinit|/remote|/remote-remove|/remote-test|/push. Reads require PermissionType.READ, mutations WRITE; the acting author is RequestContext.getActor(). The frontend lives in web-ui/ and is built from the standard @inductiveautomation/ignition-web-ui components (DataGrid, Chip, Button, PageHeader, Modal, Loading, Tooltip, and the drawer/form set Drawer/DrawerTemplate/Card/Form/FormControlInput/Radio/SelectInput/TextInput/TextArea/TextAutocomplete) — imported through src/webui.ts, a one-line shim that re-exports them cast to any (the package publishes strict internal prop types, e.g. DataGrid requires paginationParams/setTableQueryParams that have runtime defaults; the shim lets us pass only the props we need, mirroring the storybook examples). RTK Query targets a single BASE constant in src/config.ts (adjust if the live route prefix differs from /data/git-config); the base query lazily fetches /csrf and attaches the X-CSRF-Token header on mutations (the gateway's web-session access control rejects unsafe methods without it). Webpack emits a UMD bundle to mounted/gitConfig.js, packed into the gateway jar via modlImplementation(project(":web-ui")), served at /res/git-config/gitConfig.js, and mounted as component GitConfigPage.
Designer popups are Swing JDialogs parented to the Designer frame (SwingUtilities.getWindowAncestor(parent) so they overlay correctly on macOS fullscreen); abstract callbacks are overridden in anonymous subclasses inside GitActionManager. Concrete RPC signatures and callback names live in the code — don't duplicate them here.
CommitPopup— pick changes + message; "Amend last commit" pre-fills the last message and allows message-only amend; double-click a row → diff.DiffViewerPopup— side-by-side LCS line diff (green added / red removed, synced scroll); default headers HEAD/Working Tree, overridable (used byMergeConflictPopupandCommitDetailPopup).CommitDetailPopup— files in one commit + Checkout / Revert Commit; double-click a file → historical diff.MergeConflictPopup— per-file Accept Ours/Theirs, conflict diff, global Accept-All/Abort/Complete; window-close confirms abort so the repo can't be left conflicted.PullPopup/PushPopup/FetchPopup— remote selector; the lightweight Push/Fetch popups only appear with 2+ remotes (single-remote acts immediately).BranchPopup— local/remote lists, header icon buttons (create / refresh / refresh-from-remote), context-menu checkout/delete, current branch highlighted.CreateBranchPopup— branch name + Create (always from HEAD).InitRepoPopup—CardLayoutwizard: Choose → Remote (URI + credential dropdown filtered by URI scheme + Configure…) or Local (no fields; commit email comes from the Ignition user profile). Only a credential FK is passed, never inline credentials.RemotesPopup—CardLayoutlist/form for named remotes; the form picks a saved credential (storesSshKeyId/HttpsCredentialIdFK) and can openUserCredentialsPopupinline. Reached from the status-bar remotes button; the status-bar user icon opensUserCredentialsPopupdirectly (no per-project email popup).UserCredentialsPopup— user-level SSH keys + per-host HTTPS credentials, with provider hint text (GitHub/GitLab PAT, Azure PAT/empty-username, Bitbucket App Password).InitProgressDialog— modal indeterminate progress used for long ops (init/push/pull/fetch/snapshot/branch-refresh) so the EDT stays responsive.
Dockable Commit panel (CommitPanel.java, JIDE DockableFrame, tabbed by Project Browser): inline commit (message + amend), a Changes table (checkbox / Resource / Type with color-coded A/M/D/U badges and a SelectAllHeader guarded against O(n²) cascades), double-click diff, right-click View Diff / Discard. The Changes header has three snapshot buttons ("Tags"/"Themes"/"Images", VectorIcons.get("project-update") glyph) plus a refresh button; the snapshot buttons call rpc.snapshotTags/Themes/Images via GitActionManager.runSnapshot(...) on a SwingWorker+InitProgressDialog, then refresh — gateway-side edits then appear as normal file changes for per-file commit selection. Auto-refreshes every 15s and after each git op; setChangesData posts to the EDT.
Dockable History panel (HistoryPanel.java): commit log for the current branch plus the upstream tracking branch (so fetched commits show with remote ref badges before merge); borderless toolbar Refresh/Push/Fetch/Pull; Refs column rendered as colored badges; double-click → CommitDetailPopup; right-click → Checkout/Revert; "Load More" pagination; thread-safe setData.
Manager classes (gateway):
GitManager— core JGit operations:- clone; fetch (remote-tracking refs only,
setUnshallow(true)to backfill history on depth-1 repos); pull; push (current branch only by default, withpushAllBranches/pushTags/forcePushflags; non-fast-forward rejection → force-push confirmation in the Designer); commit (amend); status; branch list/create/checkout/delete with per-branch stash/restore; checkout commit (detached HEAD;getCurrentBranch()returns short-hash + "(detached)"); diff extraction; history log with ref decorations; commit file list/diff; discard; revert (aborts cleanly on conflict); remote list/add/remove/setUrl. - Lean init fetch:
materializeRepo(the registration-time clone, given the URL as a parameter) doeslsRemote().setHeads(true)to detect the default branch with no object download, a targeted single-branch shallow fetch for a fast checkout, then an unshallow fetch for full history.detectDefaultBranchFromRefsprefers main/master/develop, else the first head ref.setupLocalRepoImplis now just an idempotent startup guard (no persisted URL to re-clone from). - Auth (
setAuthentication, the sole auth path): every remote must have an explicit credential FK —GitRemoteCredentialsRecord.SshKeyIdorHttpsCredentialIdreferencing a user-levelGitUserSshKeyRecord/GitUserHttpsCredentialRecord. Throws with a clear "pick a credential in the Remotes popup" message if no FK is set or the referenced credential is gone. Auth type (SSH vs HTTPS) is read from the remote URL in.git/config. Push/pull take aremoteNameused for both the JGit target and credential lookup. Remote-dependent ops are guarded byprojectHasRemote()(backed byGitManager.listRemotes()reading.git/config) so local-only repos degrade gracefully and user-named (non-origin) remotes are recognized. - Pull conflict sentinel:
pullImplthrowsRuntimeException("MERGE_CONFLICT:" + files)onMergeStatus.CONFLICTING, caught by the Designer'shandlePullActionto openMergeConflictPopup. - Metadata noise suppression:
resource.json/thumbnail.pngare filtered from the changes list and commit file list when no sibling source file in the same resource dir also changed; applied ingetUncommitedChangesImpl/getCommitFilesImpl.
- clone; fetch (remote-tracking refs only,
GitProjectManager/GitTagManager/GitThemeManager/GitImageManager— project resource import, and gateway-resource snapshot (tags/themes/images) into the project tree. The theme snapshot stages into a system temp dir and only swaps intothemes/on full success (a mid-copy failure can't destroy committed theme files); tag snapshot bounds the provider read with a 30s timeout.
Persistence — six resource types on the 8.3 resource/config system. Each *Record class is now a mutable DTO façade over a nested NamedResourceHandler: the config is an inner Java record, and the resource name is String.valueOf(numeric id) so the RPC contract and Designer UI are unchanged (numeric long ids preserved). GatewayHook registers a ResourceTypeMeta per type and starts the handlers. A one-time records.legacy.GitLegacyImporter runs on first 8.3 startup: it registers the old SimpleORM tables' metas via SchemaUpdater (using minimal public top-level Legacy* PersistentRecord classes), reads each row, writes it as a resource, deletes the legacy row, and is idempotent (skips if the resource already exists; absent legacy tables on a fresh install are skipped). The six types:
GitProjectsConfigRecord— project registration marker (id+projectName). Holds no remote/URI data:.git/configis the sole source of truth for remotes. The clone URL is passed as a parameter toinitializeProjectand consumed bymaterializeRepoat registration time; it is never persisted. (Legacy 8.1 rows migrate identity only; their URI is dropped.)GitReposUsersRecord— project↔user registration marker only (commit email comes from the Ignition user profile; auth is via the credential records).GitRemoteCredentialsRecord— per (project, user, remote); holdsSshKeyId/HttpsCredentialIdFKs into the user-level credential tables.GitUserSshKeyRecord— user-level SSH key (IgnitionUser,KeyName); the key is aSecretConfig(sshKeySecret) — embedded-encrypted or a Secret-Provider reference, same as the HTTPS password — with the legacy plaintextsshKeykept nullable so pre-migration rows still deserialize and upgrade on next save;getSSHKey()decrypts/falls-back unchanged for Designer callers. Shared across projects/remotes.GitConfigRemoteRecord— gateway-level singleton: the data-dir config repo's remote (URI, branch, credential FK). Manual push only.GitUserHttpsCredentialRecord— user-level HTTPS credential (IgnitionUser,HostPattern,UserName,Password). The password is held as aSecretConfig.embedded(...): encrypted onsetPasswordviaGatewayContext.getSystemEncryptionService().encryptToJson(Plaintext)and decrypted ongetPasswordviaSecret.create(ctx, secretConfig).getPlaintext().HostPatternis purely an organizational label / disambiguator in the credential picker — auth never matches on it; remotes resolve credentials only via their explicit FK.
- Eclipse JGit 6.10.1 — all git operations
- Apache MINA sshd (
org.eclipse.jgit.ssh.apache) — SSH transport (replaced the deprecated JSch backend) - Lombok 1.18.42 — annotation processing (designer)
- IntelliJ forms_rt 7.0.3 — Swing form support for popups
- React 18 + RTK Query +
@inductiveautomation/ignition-web-ui— theweb-ui/gateway page (externals provided by the gateway; not bundled), built viacom.github.node-gradle.node+ webpack into a UMD bundle
io.ia.sdk.modl plugin (v0.4.1). Module ID com.operametrix.ignition.git; version is 2.0.0.<yyyyMMddHH> (the build appends a timestamp). The compile SDK (sdk_version = 8.3.6, latest stable) is deliberately decoupled from requiredIgnitionVersion (min_ignition_version = 8.3.0) so the .modl installs on any 8.3.x gateway — don't recouple them. 8.3 declares module deps via moduleDependencySpecs { } (empty here), replacing the old moduleDependencies. The :web-ui subproject has no module scope (it's not in projectScopes); its built bundle is pulled into the gateway jar via modlImplementation(project(":web-ui")) in gateway/build.gradle.kts, and settings.gradle adds the Node.js ivy repo so the node-gradle plugin can fetch the Node runtime. skipModlSigning is false (signing enabled) — copy gradle.template.properties to gradle.properties (gitignored) and fill in signing credentials, or flip skipModlSigning to true locally for unsigned dev builds.
Java 17 source/target, set via the toolchain in each subproject's build.gradle.kts.
Resolved from Inductive Automation's Nexus and Maven Central, configured in settings.gradle.