Update dependencies and implement git-based sync direction defaults

- Updated `clap` and `unicode-ident` versions in `Cargo.lock` and `Cargo.toml`.
- Added `sha1` crate to `Cargo.toml` for SHA1 hashing functionality.
- Introduced a new `git.rs` module for handling git operations, including changed files and SHA1 computation.
- Enhanced the sync confirmation dialog to auto-select defaults based on git status, improving user experience during two-way sync.
- Updated `PatchTree` to utilize git metadata for determining default selections during patch application.

These changes enhance the integration with git, providing smarter defaults and improving the overall sync process.
This commit is contained in:
Joe 2026-02-17 20:36:28 -06:00
parent df22500102
commit 7ba04f7f6a
29 changed files with 1285 additions and 176 deletions

View file

@ -188,7 +188,7 @@ function ApiContext:read(ids)
end)
end
function ApiContext:write(patch)
function ApiContext:write(patch, stageIds)
local url = ("%s/api/write"):format(self.__baseUrl)
-- Log what we're sending to the server (syncback/pull)
@ -248,6 +248,7 @@ function ApiContext:write(patch)
removed = patch.removed,
updated = updated,
added = added,
stageIds = stageIds,
}
body = Http.msgpackEncode(body)

View file

@ -653,10 +653,12 @@ function App:startSession()
})
end)
local cachedServerInfo = nil
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }, gitMetadata),
})
end)
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
@ -790,6 +792,8 @@ function App:startSession()
local initialSyncConfirmed = false
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
cachedServerInfo = serverInfo
-- Filter out the DataModel name change from the patch
-- The project name (DataModel.Name) is managed by Studio independently
PatchSet.removeDataModelName(patch, instanceMap)
@ -864,7 +868,7 @@ function App:startSession()
})
self:setState({
appStatus = AppStatus.Confirming,
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, serverInfo.gitMetadata),
confirmData = {
serverInfo = serverInfo,
},
@ -914,8 +918,9 @@ function App:startSession()
-- Update the patchTree when new changes arrive during confirmation
-- The changedIds parameter contains IDs of items that were modified
-- so their selections can be reset (user must re-review)
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata),
changedIds = changedIds, -- Pass to UI so it can unselect changed items
})
end)
@ -1045,7 +1050,19 @@ function App:render()
self.confirmationBindable:Fire("Abort")
end,
onConfirm = function(selections)
self.confirmationBindable:Fire({ type = "Confirm", selections = selections })
local autoSelectedIds = {}
if self.state.patchTree then
self.state.patchTree:forEach(function(node)
if node.patchType and node.defaultSelection ~= nil then
autoSelectedIds[node.id] = true
end
end)
end
self.confirmationBindable:Fire({
type = "Confirm",
selections = selections,
autoSelectedIds = autoSelectedIds,
})
end,
}),

View file

@ -13,6 +13,7 @@ local Log = require(Packages.Log)
local ChangeMetadata = require(Plugin.ChangeMetadata)
local Config = require(Plugin.Config)
local SHA1 = require(Plugin.SHA1)
local Timer = require(Plugin.Timer)
local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue)
@ -212,7 +213,8 @@ local PatchTree = {}
-- Builds a new tree from a patch and instanceMap
-- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders)
-- gitMetadata is optional: { changedIds: {string}, scriptCommittedHashes: {[string]: {string}} }
function PatchTree.build(patch, instanceMap, changeListHeaders, gitMetadata)
Timer.start("PatchTree.build")
local clock = os.clock()
@ -220,6 +222,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
local knownAncestors = {}
local changedIdSet = nil
if gitMetadata and gitMetadata.changedIds then
changedIdSet = {}
for _, id in gitMetadata.changedIds do
changedIdSet[id] = true
end
end
Timer.start("patch.updated")
for _, change in patch.updated do
clock = yieldIfNeeded(clock)
@ -304,8 +314,28 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
table.insert(changeList, 1, changeListHeaders)
end
-- Add this node to tree
-- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip
-- Compute default selection from git metadata
local defaultSelection = nil
if changedIdSet then
if not changedIdSet[change.id] then
defaultSelection = "pull"
elseif instance:IsA("LuaSourceContainer")
and gitMetadata.scriptCommittedHashes then
local hashes = gitMetadata.scriptCommittedHashes[change.id]
if hashes then
local source = instance.Source
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
local studioHash = SHA1(buffer.fromstring(gitBlob))
for _, hash in hashes do
if studioHash == hash then
defaultSelection = "push"
break
end
end
end
end
end
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = change.id,
patchType = "Edit",
@ -314,7 +344,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
instance = instance,
changeInfo = changeInfo,
changeList = changeList,
defaultSelection = nil,
defaultSelection = defaultSelection,
})
end
Timer.stop()

107
plugin/src/SHA1.luau Normal file
View file

@ -0,0 +1,107 @@
--!strict
--!optimize 2
--!native
local OFFSETS = buffer.create(320)
local function PreProcess(Contents: buffer): (buffer, number)
local ContentLength = buffer.len(Contents)
local Padding = (64 - ((ContentLength + 9) % 64)) % 64
local NewContentLength = ContentLength + 1 + Padding + 8
local NewContent = buffer.create(NewContentLength)
buffer.copy(NewContent, 0, Contents)
buffer.writeu8(NewContent, ContentLength, 128)
local Length8 = ContentLength * 8
for Index = 7, 0, -1 do
local Remainder = Length8 % 256
buffer.writeu8(NewContent, Index + ContentLength + 1 + Padding, Remainder)
Length8 = (Length8 - Remainder) / 256
end
return NewContent, NewContentLength
end
local function DigestBlocks(Blocks: buffer, Length: number): (number, number, number, number, number)
local A, B, C, D, E = 0x67452301, 0xefcdaB89, 0x98badcfe, 0x10325476, 0xc3d2e1f0
local Offsets = OFFSETS
for Offset = 0, Length - 1, 64 do
for BlockIndex = 0, 60, 4 do
buffer.writeu32(Offsets, BlockIndex, bit32.byteswap(buffer.readu32(Blocks, Offset + BlockIndex)))
end
for Index = 64, 316, 4 do
buffer.writeu32(Offsets, Index, bit32.lrotate(bit32.bxor(
buffer.readu32(Offsets, Index - 12),
buffer.readu32(Offsets, Index - 32),
buffer.readu32(Offsets, Index - 56),
buffer.readu32(Offsets, Index - 64)
), 1))
end
local H1, H2, H3, H4, H5 = A, B, C, D, E
local Temp
for Round = 0, 19 do
Temp = bit32.lrotate(H1, 5) +
bit32.band(H2, H3) + bit32.band(-1 - H2, H4) +
H5 + 0x5a827999 +
buffer.readu32(Offsets, Round * 4)
H5, H4, H3, H2, H1 = H4, H3, bit32.lrotate(H2, 30), H1, Temp
end
for Round = 20, 39 do
Temp = bit32.lrotate(H1, 5) +
bit32.bxor(H2, H3, H4) +
H5 + 0x6ed9eba1 +
buffer.readu32(Offsets, Round * 4)
H5, H4, H3, H2, H1 = H4, H3, bit32.lrotate(H2, 30), H1, Temp
end
for Round = 40, 59 do
Temp = bit32.lrotate(H1, 5) +
bit32.band(H4, H3) + bit32.band(H2, bit32.bxor(H4, H3)) +
H5 + 0x8f1bbcdc +
buffer.readu32(Offsets, Round * 4)
H5, H4, H3, H2, H1 = H4, H3, bit32.lrotate(H2, 30), H1, Temp
end
for Round = 60, 79 do
Temp = bit32.lrotate(H1, 5) +
bit32.bxor(H2, H3, H4) +
H5 + 0xca62c1d6 +
buffer.readu32(Offsets, Round * 4)
H5, H4, H3, H2, H1 = H4, H3, bit32.lrotate(H2, 30), H1, Temp
end
A = bit32.bor(A + H1, 0)
B = bit32.bor(B + H2, 0)
C = bit32.bor(C + H3, 0)
D = bit32.bor(D + H4, 0)
E = bit32.bor(E + H5, 0)
end
return A, B, C, D, E
end
local function SHA1(Message: buffer, Salt: buffer?): string
if Salt and buffer.len(Salt) > 0 then
local MessageWithSalt = buffer.create(buffer.len(Message) + buffer.len(Salt))
buffer.copy(MessageWithSalt, 0, Message)
buffer.copy(MessageWithSalt, buffer.len(Message), Salt)
Message = MessageWithSalt
end
local ProcessedMessage, Length = PreProcess(Message)
return string.format("%08x%08x%08x%08x%08x", DigestBlocks(ProcessedMessage, Length))
end
return SHA1

View file

@ -695,6 +695,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
elseif type(userDecision) == "table" and userDecision.type == "Confirm" then
-- New: Apply based on per-item selections
local selections = userDecision.selections or {}
local autoSelectedIds = userDecision.autoSelectedIds or {}
-- Log selection summary (debug level)
local pushCount, pullCount, ignoreCount = 0, 0, 0
@ -713,6 +714,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
local pushPatch = PatchSet.newEmpty() -- Items to apply to Studio
local pullPatch = PatchSet.newEmpty() -- Items to send back to Rojo
-- Build stage_ids: all push-accepted IDs + auto-selected pull-accepted IDs
local stageIds = {}
-- Process updated items
for _, change in catchUpPatch.updated do
local selection = selections[change.id]
@ -723,6 +727,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
-- Apply Rojo changes to Studio
Log.info("[Push] Update: {}", instancePath)
table.insert(pushPatch.updated, change)
table.insert(stageIds, change.id)
elseif selection == "pull" and self.__twoWaySync then
-- Send Studio state back to Rojo
if instance then
@ -749,6 +754,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
)
if update then
table.insert(pullPatch.updated, update)
if autoSelectedIds[change.id] then
table.insert(stageIds, change.id)
end
end
end
end
@ -778,6 +786,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
-- Apply Rojo removal to Studio (delete the instance)
Log.info("[Push] Delete: {}", instancePath)
table.insert(pushPatch.removed, idOrInstance)
if id then
table.insert(stageIds, id)
end
elseif selection == "pull" and self.__twoWaySync then
-- Syncback: Create file in Rojo from Studio instance
if instance and instance.Parent then
@ -790,6 +801,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
local guid = HttpService:GenerateGUID(false)
local tempRef = string.gsub(guid, "-", ""):lower()
pullPatch.added[tempRef] = encoded
if id and autoSelectedIds[id] then
table.insert(stageIds, tempRef)
end
-- Note: We intentionally do NOT pre-insert into InstanceMap here.
-- The VFS watcher will process the written files and assign a
-- server-side Ref ID. Any Ref properties targeting this instance
@ -818,6 +832,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
-- Apply Rojo addition to Studio
Log.info("[Push] Add: {}.{}", parentPath, instanceName)
pushPatch.added[id] = change
table.insert(stageIds, id)
elseif selection == "pull" and self.__twoWaySync then
-- Don't add in Studio, remove from Rojo
-- Log at info level since this is a major file deletion operation
@ -846,19 +861,27 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
self:__applyPatch(pushPatch)
end
-- Send pull items back to Rojo server
if self.__twoWaySync and not PatchSet.isEmpty(pullPatch) then
local addCount = 0
for _ in pairs(pullPatch.added) do
addCount += 1
-- Send pull items + stage requests to Rojo server
local hasPullChanges = self.__twoWaySync and not PatchSet.isEmpty(pullPatch)
local hasStageIds = #stageIds > 0
if hasPullChanges or hasStageIds then
if hasPullChanges then
local addCount = 0
for _ in pairs(pullPatch.added) do
addCount += 1
end
Log.info(
"Sending to Rojo: {} file creations, {} file deletions, {} updates",
addCount,
#pullPatch.removed,
#pullPatch.updated
)
end
Log.info(
"Sending to Rojo: {} file creations, {} file deletions, {} updates",
addCount,
#pullPatch.removed,
#pullPatch.updated
)
return self.__apiContext:write(pullPatch)
if hasStageIds then
Log.info("Requesting git stage for {} files", #stageIds)
end
return self.__apiContext:write(pullPatch, stageIds)
end
return Promise.resolve()