rojo/plugin/src/PatchSet.lua
Joe a98d5c6cce Enhance PatchSet data model update handling and update default project path in CLI
- Improved the `removeDataModelName` function in PatchSet to remove names from both `changedName` and `changedProperties`, ensuring more robust update management.
- Updated the default project path in the ServeCommand struct to `default.project.json5` for clearer project serving expectations.
2026-01-30 11:35:49 -06:00

549 lines
13 KiB
Lua

--[[
Methods to operate on either a patch created by the hydrate method, or a
patch returned from the API.
]]
local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)
local Types = require(script.Parent.Types)
local decodeValue = require(script.Parent.Reconciler.decodeValue)
local function deepEqual(a: any, b: any): boolean
local typeA = typeof(a)
if typeA ~= typeof(b) then
return false
end
if typeof(a) == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if deepEqual(value, b[key]) == false then
return false
end
end
for key, value in b do
if checkedKeys[key] then
continue
end
if deepEqual(value, a[key]) == false then
return false
end
end
return true
end
if a == b then
return true
end
return false
end
local PatchSet = {}
PatchSet.validate = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Create a new, empty PatchSet.
]]
function PatchSet.newEmpty()
return {
removed = {},
added = {},
updated = {},
}
end
--[[
Tells whether the given PatchSet is empty.
]]
function PatchSet.isEmpty(patchSet)
return next(patchSet.removed) == nil and next(patchSet.added) == nil and next(patchSet.updated) == nil
end
--[[
Tells whether the given PatchSet has any remove operations.
]]
function PatchSet.hasRemoves(patchSet)
return next(patchSet.removed) ~= nil
end
--[[
Tells whether the given PatchSet has any add operations.
]]
function PatchSet.hasAdditions(patchSet)
return next(patchSet.added) ~= nil
end
--[[
Tells whether the given PatchSet has any update operations.
]]
function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
end
--[[
Tells whether the given PatchSet contains changes to the given instance id
]]
function PatchSet.containsId(patchSet, instanceMap, id)
if patchSet.added[id] ~= nil then
return true
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId == id then
return true
end
end
for _, update in patchSet.updated do
if update.id == id then
return true
end
end
return false
end
--[[
Tells whether the given PatchSet contains changes to the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsId(patchSet, instanceMap, id)
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance id
]]
function PatchSet.containsOnlyId(patchSet, instanceMap, id)
if not PatchSet.containsId(patchSet, instanceMap, id) then
-- Patch doesn't contain the id at all
return false
end
for addedId in patchSet.added do
if addedId ~= id then
return false
end
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId ~= id then
return false
end
end
for _, update in patchSet.updated do
if update.id ~= id then
return false
end
end
return true
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsOnlyInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsOnlyId(patchSet, instanceMap, id)
end
--[[
Returns the update to the given instance id, or nil if there aren't any
]]
function PatchSet.getUpdateForId(patchSet, id)
for _, update in patchSet.updated do
if update.id == id then
return update
end
end
return nil
end
--[[
Returns the update to the given instance, or nil if there aren't any.
If the given InstanceMap does not contain the instance, this function always returns nil.
]]
function PatchSet.getUpdateForInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return nil
end
return PatchSet.getUpdateForId(patchSet, id)
end
--[[
Tells whether the given PatchSets are equal.
]]
function PatchSet.isEqual(patchA, patchB)
return deepEqual(patchA, patchB)
end
--[[
Count the number of changes in the given PatchSet.
]]
function PatchSet.countChanges(patch)
local count = 0
for _, add in patch.added do
-- Adding an instance is 1 change per property
for _ in add.Properties do
count += 1
end
end
for _ in patch.removed do
-- Removing an instance is 1 change
count += 1
end
for _, update in patch.updated do
-- Updating an instance is 1 change per property updated
for _ in update.changedProperties do
count += 1
end
if update.changedName ~= nil then
count += 1
end
if update.changedClassName ~= nil then
count += 1
end
end
return count
end
--[[
Count the number of instances affected by the given PatchSet.
]]
function PatchSet.countInstances(patch)
local count = 0
-- Added instances
for _ in patch.added do
count += 1
end
-- Removed instances
for _ in patch.removed do
count += 1
end
-- Updated instances
for _ in patch.updated do
count += 1
end
return count
end
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]
function PatchSet.assign(target, ...)
for i = 1, select("#", ...) do
local sourcePatch = select(i, ...)
for _, removed in ipairs(sourcePatch.removed) do
table.insert(target.removed, removed)
end
for id, added in pairs(sourcePatch.added) do
target.added[id] = added
end
for _, update in ipairs(sourcePatch.updated) do
table.insert(target.updated, update)
end
end
return target
end
--[[
Merge a source PatchSet into the target, properly reconciling conflicts.
Unlike assign(), this handles cases where a removal cancels out an addition,
or where an update supersedes a previous update to the same instance.
If instanceMap is provided, updates that revert to the current instance state
will be removed from the patch entirely.
]]
function PatchSet.merge(target, source, instanceMap)
-- Process removals: if we're removing something that was added, just delete the addition
for _, removed in ipairs(source.removed) do
if target.added[removed] ~= nil then
-- This removal cancels out a previous addition
target.added[removed] = nil
else
table.insert(target.removed, removed)
end
end
-- Process additions
for id, added in pairs(source.added) do
target.added[id] = added
end
-- Process updates: merge with existing updates to the same instance
for _, update in ipairs(source.updated) do
local existingIndex = nil
for i, existing in ipairs(target.updated) do
if existing.id == update.id then
existingIndex = i
break
end
end
if existingIndex then
-- Merge the updates
local existing = target.updated[existingIndex]
if update.changedName ~= nil then
existing.changedName = update.changedName
end
if update.changedClassName ~= nil then
existing.changedClassName = update.changedClassName
end
if update.changedMetadata ~= nil then
existing.changedMetadata = update.changedMetadata
end
for prop, value in pairs(update.changedProperties) do
existing.changedProperties[prop] = value
end
-- Check if this update now matches current instance state (i.e., reverted)
-- If so, remove properties that match or remove the update entirely
if instanceMap then
local instance = instanceMap.fromIds[existing.id]
if instance then
-- Check if name change matches current
if existing.changedName ~= nil and existing.changedName == instance.Name then
existing.changedName = nil
end
-- Check if class change matches current
if existing.changedClassName ~= nil and existing.changedClassName == instance.ClassName then
existing.changedClassName = nil
end
-- Check each changed property against current instance value
for propName, encodedValue in pairs(existing.changedProperties) do
local decodeSuccess, decodedValue = decodeValue(encodedValue, instanceMap)
if decodeSuccess then
local currentSuccess, currentValue = pcall(function()
return instance[propName]
end)
if currentSuccess and deepEqual(decodedValue, currentValue) then
existing.changedProperties[propName] = nil
end
end
end
-- If no changes remain, remove the update entirely
local hasChanges = existing.changedName ~= nil
or existing.changedClassName ~= nil
or existing.changedMetadata ~= nil
or next(existing.changedProperties) ~= nil
if not hasChanges then
table.remove(target.updated, existingIndex)
end
end
end
else
table.insert(target.updated, update)
end
end
return target
end
function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
table.insert(idList, id)
end
return table.freeze(idList)
end
function PatchSet.updatedIdList(patchSet): { string }
local idList = table.create(#patchSet.updated)
for _, item in patchSet.updated do
table.insert(idList, item.id)
end
return table.freeze(idList)
end
--[[
Remove the Name property change from the DataModel update in the patch.
This prevents the project name from being synced, which is usually
unwanted as Studio manages the DataModel name independently.
Mutates the patch in place for efficiency.
]]
function PatchSet.removeDataModelName(patchSet, instanceMap)
local dataModelId = instanceMap.fromInstances[game]
if dataModelId == nil then
return
end
for i, update in ipairs(patchSet.updated) do
if update.id == dataModelId then
-- Remove Name from both changedName and changedProperties
local hadChanges = false
if update.changedName ~= nil then
update.changedName = nil
hadChanges = true
end
if update.changedProperties and update.changedProperties.Name ~= nil then
update.changedProperties.Name = nil
hadChanges = true
end
if not hadChanges then
return
end
-- Remove update entirely if no other changes remain
if
update.changedName == nil
and update.changedClassName == nil
and update.changedMetadata == nil
and (update.changedProperties == nil or next(update.changedProperties) == nil)
then
table.remove(patchSet.updated, i)
end
return
end
end
end
--[[
Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users.
]]
function PatchSet.humanSummary(instanceMap, patchSet)
local statements = {}
for _, idOrInstance in ipairs(patchSet.removed) do
local instance, id
if Types.RbxId(idOrInstance) then
id = idOrInstance
instance = instanceMap.fromIds[id]
else
instance = idOrInstance
id = instanceMap.fromInstances[instance]
end
if instance ~= nil then
table.insert(statements, string.format("- Delete instance %s", instance:GetFullName()))
else
table.insert(statements, string.format("- Delete instance with ID %s", id))
end
end
local additionsMentioned = {}
local function addAllDescendents(virtualInstance)
additionsMentioned[virtualInstance.Id] = true
for _, childId in ipairs(virtualInstance.Children) do
addAllDescendents(patchSet.added[childId])
end
end
for id, addition in pairs(patchSet.added) do
if additionsMentioned[id] then
continue
end
local virtualInstance = addition
while true do
if virtualInstance.Parent == nil then
break
end
local virtualParent = patchSet.added[virtualInstance.Parent]
if virtualParent == nil then
break
end
virtualInstance = virtualParent
end
local parentDisplayName = "nil (how strange!)"
if virtualInstance.Parent ~= nil then
local parent = instanceMap.fromIds[virtualInstance.Parent]
if parent ~= nil then
parentDisplayName = parent:GetFullName()
end
end
table.insert(
statements,
string.format(
"- Add instance %q (ClassName %q) to %s",
virtualInstance.Name,
virtualInstance.ClassName,
parentDisplayName
)
)
end
for _, update in ipairs(patchSet.updated) do
local updatedProperties = {}
if update.changedMetadata ~= nil then
table.insert(updatedProperties, "Rojo's Metadata")
end
if update.changedName ~= nil then
table.insert(updatedProperties, "Name")
end
if update.changedClassName ~= nil then
table.insert(updatedProperties, "ClassName")
end
for name in pairs(update.changedProperties) do
table.insert(updatedProperties, name)
end
local instance = instanceMap.fromIds[update.id]
local displayName
if instance ~= nil then
displayName = instance:GetFullName()
else
displayName = "[unknown instance]"
end
table.insert(
statements,
string.format("- Update properties on %s: %s", displayName, table.concat(updatedProperties, ","))
)
end
return table.concat(statements, "\n")
end
return PatchSet