mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
df22500102
commit
7ba04f7f6a
29 changed files with 1285 additions and 176 deletions
329
.cursor/plans/git-based_sync_defaults_be6371b6.plan.md
Normal file
329
.cursor/plans/git-based_sync_defaults_be6371b6.plan.md
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
---
|
||||||
|
name: Git-Based Sync Defaults
|
||||||
|
overview: Add git-aware default sync direction selection to the two-way sync confirmation UI. Server sends changed-instance IDs + SHA1 hashes (both staged and HEAD) to the plugin for auto-selecting defaults. Accepted files are auto-staged via git add.
|
||||||
|
todos:
|
||||||
|
- id: sha1-dep
|
||||||
|
content: Add sha1 crate to Cargo.toml
|
||||||
|
status: completed
|
||||||
|
- id: sha1-luau
|
||||||
|
content: Add SHA1.luau to plugin/src/ from user-provided file
|
||||||
|
status: completed
|
||||||
|
- id: git-module
|
||||||
|
content: Create src/git.rs with git CLI helpers (repo root, changed files, committed content, SHA1 computation, git add)
|
||||||
|
status: completed
|
||||||
|
- id: interface-rs
|
||||||
|
content: Add GitMetadata struct and field to ServerInfoResponse, add stage_ids to WriteRequest and PatchSet
|
||||||
|
status: completed
|
||||||
|
- id: api-rs
|
||||||
|
content: Call compute_git_metadata in handle_api_rojo (two-phase lock), wire up project root, handle stage_ids in handle_api_write
|
||||||
|
status: completed
|
||||||
|
- id: patchtree-lua
|
||||||
|
content: Update PatchTree.build to accept gitMetadata and set defaultSelection based on git status + SHA1 hash comparison
|
||||||
|
status: completed
|
||||||
|
- id: app-init-lua
|
||||||
|
content: Pass serverInfo.gitMetadata to PatchTree.build call sites in App/init.lua, build stage_ids in confirmation flow
|
||||||
|
status: completed
|
||||||
|
- id: auto-stage
|
||||||
|
content: "Auto git-add: all push-accepted files + auto-selected pull-accepted files. Split staging between api.rs and change_processor."
|
||||||
|
status: completed
|
||||||
|
- id: test-verify
|
||||||
|
content: "Build and verify: cargo build, cargo test, cargo clippy"
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git-Based Sync Direction Defaults
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When the plugin shows the initial sync confirmation dialog, currently all items default to `nil` (unselected). This feature adds smart defaults based on git status:
|
||||||
|
|
||||||
|
- **File has no git changes** (same as HEAD): default to "Studio" (pull) -- any diff is because Studio was changed
|
||||||
|
- **File has git changes AND is a script AND Studio Source matches committed version**: default to "Atlas" (push) -- developer changed the file, Studio hasn't diverged from the last commit
|
||||||
|
- **Otherwise**: `nil` -- user must decide (both sides changed from committed, or non-script with changes)
|
||||||
|
- **No git repo**: all defaults remain `nil` (feature is no-op)
|
||||||
|
|
||||||
|
These defaults only apply to **Updated** items (where both Studio and filesystem have the instance). Added/Removed items remain `nil` to avoid destructive auto-selections.
|
||||||
|
|
||||||
|
**Hash comparison is scripts-only for now.** Non-script property/misc changes that are git-changed remain `nil` (deferred feature -- would require reproducing a bit-for-bit state on the Roblox side to compare).
|
||||||
|
|
||||||
|
Recomputed fresh on **each connect** (not cached).
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. **Scripts changed locally, hashes match Studio Source**: Developer changed scripts in git. Plugin receives committed SHA1 hashes. Studio's `script.Source` (non-draft version) matches the committed hashes. Result: **"Atlas" auto-selected** for those scripts.
|
||||||
|
2. **Scripts changed by someone else too**: Developer pulled commits that changed files, AND someone edited in Studio. Committed SHA1 won't match Studio Source. Result: **No bubble selected** -- user must decide.
|
||||||
|
3. **Property/misc changes in git**: Non-script files flagged as changed. Result: **No auto-selection** (deferred feature).
|
||||||
|
4. **No git changes on a file**: File is identical to HEAD. Any diff is because Studio was changed. Result: **"Studio" auto-selected**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Git as Git CLI
|
||||||
|
participant Server as Rust Server
|
||||||
|
participant Tree as RojoTree
|
||||||
|
participant Plugin as Lua Plugin
|
||||||
|
participant UI as PatchTree / UI
|
||||||
|
|
||||||
|
Note over Server: On each GET /api/rojo
|
||||||
|
Server->>Tree: snapshot path_to_ids + class names (brief lock)
|
||||||
|
Server->>Git: git rev-parse --show-toplevel
|
||||||
|
Git-->>Server: repo root (or fail = no git)
|
||||||
|
Server->>Git: git diff HEAD --name-only
|
||||||
|
Git-->>Server: list of changed files
|
||||||
|
Server->>Server: map paths to Refs using snapshot
|
||||||
|
loop For each changed script with committed version
|
||||||
|
Server->>Git: git show HEAD:path + git show :0:path
|
||||||
|
Git-->>Server: committed + staged content
|
||||||
|
Server->>Server: SHA1 in blob format (no normalization)
|
||||||
|
end
|
||||||
|
Server->>Plugin: ServerInfoResponse + gitMetadata
|
||||||
|
|
||||||
|
Note over Plugin: Building PatchTree
|
||||||
|
Plugin->>UI: For each Updated node
|
||||||
|
alt Not in changedIds
|
||||||
|
UI->>UI: defaultSelection = "pull"
|
||||||
|
else Script with committed hash
|
||||||
|
Plugin->>Plugin: SHA1 blob format of script.Source
|
||||||
|
alt Any hash matches
|
||||||
|
UI->>UI: defaultSelection = "push"
|
||||||
|
else No hash matches
|
||||||
|
UI->>UI: defaultSelection = nil
|
||||||
|
end
|
||||||
|
else Non-script or no committed hash
|
||||||
|
UI->>UI: defaultSelection = nil
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Server Side (Rust)
|
||||||
|
|
||||||
|
### 1. Add `sha1` crate dependency
|
||||||
|
|
||||||
|
In [Cargo.toml](Cargo.toml), add under `[dependencies]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
sha1 = "0.10"
|
||||||
|
```
|
||||||
|
|
||||||
|
We compute hashes ourselves using the git blob format scheme (`"blob <size>\0<content>"`) so both sides produce identical hashes from identical content.
|
||||||
|
|
||||||
|
### 2. New module: `src/git.rs`
|
||||||
|
|
||||||
|
All git interaction via `std::process::Command`.
|
||||||
|
|
||||||
|
- `**git_repo_root(project_root: &Path) -> Option<PathBuf>**`: Runs `git -C <dir> rev-parse --show-toplevel`. Returns `None` if not a git repo or git not installed.
|
||||||
|
- `**git_changed_files(repo_root: &Path) -> Option<HashSet<PathBuf>>**`: Runs `git -C <dir> diff HEAD --name-only` to get files differing from HEAD (covers staged + unstaged). Also runs `git -C <dir> ls-files --others --exclude-standard` for untracked files. Returns paths relative to repo root. **Edge case**: if HEAD doesn't exist (no commits yet), treat all tracked files as changed or return None gracefully.
|
||||||
|
- `**git_show_head(repo_root: &Path, rel_path: &Path) -> Option<String>`**: Runs `git -C <dir> show HEAD:<path>`. Returns committed content at HEAD. Returns `None` for new files.
|
||||||
|
- `**git_show_staged(repo_root: &Path, rel_path: &Path) -> Option<String>`**: Runs `git -C <dir> show :0:<path>`. Returns staged (index) content. Returns `None` if not staged or file doesn't exist in index.
|
||||||
|
- `**compute_blob_sha1(content: &str) -> String`**: Computes `SHA1("blob <byte_len>\0<content>")`. Returns 40-char lowercase hex string. No normalization.
|
||||||
|
- `**git_add(repo_root: &Path, paths: &[PathBuf])`**: Runs `git -C <dir> add -- <paths...>`. Stages files for commit. No-op if paths is empty.
|
||||||
|
- `**compute_git_metadata(path_snapshot: &PathSnapshot, project_root: &Path) -> Option<GitMetadata>`**: Takes a pre-extracted `PathSnapshot` (see section 4). Orchestrates git commands:
|
||||||
|
1. Find repo root
|
||||||
|
2. Get changed files set
|
||||||
|
3. Map changed file paths to instance Refs using the path snapshot
|
||||||
|
4. For each changed script: collect up to 2 hashes -- HEAD version and staged version (skip staged if identical to HEAD)
|
||||||
|
5. Returns `None` if no git repo
|
||||||
|
|
||||||
|
### 3. Update data structures in [src/web/interface.rs](src/web/interface.rs)
|
||||||
|
|
||||||
|
**GitMetadata** (new struct for ServerInfoResponse):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitMetadata {
|
||||||
|
/// Instance IDs whose backing files have changes relative to HEAD
|
||||||
|
pub changed_ids: Vec<Ref>,
|
||||||
|
/// For changed script instances: SHA1 hashes of prior versions (git blob format).
|
||||||
|
/// Contains 1-2 hashes per script: HEAD version and staged version (if different).
|
||||||
|
/// Plugin checks if Studio Source matches ANY hash to default "Atlas".
|
||||||
|
pub script_committed_hashes: HashMap<Ref, Vec<String>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `ServerInfoResponse`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub git_metadata: Option<GitMetadata>,
|
||||||
|
```
|
||||||
|
|
||||||
|
**WriteRequest** (add `stage_ids` field):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct WriteRequest {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub removed: Vec<Ref>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub added: HashMap<Ref, AddedInstance>,
|
||||||
|
pub updated: Vec<InstanceUpdate>,
|
||||||
|
/// Instance IDs whose backing files should be staged via git add.
|
||||||
|
/// Includes: all push-accepted IDs + auto-selected pull-accepted IDs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_ids: Vec<Ref>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PatchSet** (add `stage_ids` field for change_processor):
|
||||||
|
|
||||||
|
Add to the existing PatchSet struct in [src/snapshot/patch.rs](src/snapshot/patch.rs):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Instance IDs whose backing files should be staged after writing.
|
||||||
|
/// Populated from WriteRequest.stage_ids, consumed by change_processor.
|
||||||
|
#[serde(default, skip)]
|
||||||
|
pub stage_ids: HashSet<Ref>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update `handle_api_rojo()` in [src/web/api.rs](src/web/api.rs) -- two-phase lock
|
||||||
|
|
||||||
|
**Critical**: Do NOT hold the tree lock while running git subprocesses.
|
||||||
|
|
||||||
|
**Phase 1 (with lock)**: Snapshot the data needed from the tree:
|
||||||
|
|
||||||
|
- `path_to_ids` entries (clone the relevant mappings)
|
||||||
|
- Instance class names for each Ref
|
||||||
|
- `instigating_source` paths
|
||||||
|
|
||||||
|
**Phase 2 (no lock)**: Run git commands and compute hashes using the snapshot.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Phase 1: brief lock
|
||||||
|
let path_snapshot = {
|
||||||
|
let tree = self.serve_session.tree();
|
||||||
|
extract_path_snapshot(&tree) // Clone path_to_ids, class names, instigating sources
|
||||||
|
};
|
||||||
|
// Lock released here
|
||||||
|
|
||||||
|
// Phase 2: slow git work, no lock held
|
||||||
|
let git_metadata = compute_git_metadata(&path_snapshot, project_root);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Wire up in [src/serve_session.rs](src/serve_session.rs)
|
||||||
|
|
||||||
|
Expose `project_root()` from `ServeSession` (the project file's parent directory). Compute and store `git_repo_root` as `Option<PathBuf>` at session start for use by auto-staging in `handle_api_write` and change_processor.
|
||||||
|
|
||||||
|
### 5b. Auto-stage accepted files
|
||||||
|
|
||||||
|
After the user confirms sync selections, automatically `git add` affected files based on these rules:
|
||||||
|
|
||||||
|
- **Push (Atlas) accepted**: Always stage the backing file. The filesystem changes are being finalized into Studio, so they're ready to commit. Applies to ALL push items (auto-selected and manually-selected), including non-scripts.
|
||||||
|
- **Pull (Studio) accepted**: Only stage if the item was auto-selected (had a non-nil `defaultSelection`). Manually-chosen pulls are left unstaged so the user can diff them.
|
||||||
|
|
||||||
|
**Staging is split between two components** to handle the async Source write timing:
|
||||||
|
|
||||||
|
1. `**handle_api_write()` in [src/web/api.rs](src/web/api.rs)**: Stages files that api.rs writes directly -- additions (new files), removals (deleted files), and push-accepted files (already on disk, just need staging). Resolves Ref IDs to filesystem paths via `instigating_source` in the tree. Calls `git_add` in one batch. Also copies `stage_ids` into the PatchSet before sending to change_processor.
|
||||||
|
2. `**handle_tree_event()` in [src/change_processor.rs](src/change_processor.rs)**: After writing Source to disk (line ~1807), checks if the instance's Ref is in `patch.stage_ids`. If so, calls `git_add` on the written path. The change_processor receives `repo_root: Option<PathBuf>` during construction (computed once at session start).
|
||||||
|
|
||||||
|
**Why split?** Source writes are async -- api.rs sends the PatchSet to change_processor via a channel and returns immediately. If api.rs staged those files, it would stage the OLD content before change_processor writes the new content. Staging must happen AFTER the write.
|
||||||
|
|
||||||
|
**Plugin side** ([plugin/src/ServeSession.lua](plugin/src/ServeSession.lua)): In `__confirmAndApplyInitialPatch`, build the `stage_ids` list:
|
||||||
|
|
||||||
|
- For each push-selected item: always include in `stage_ids`
|
||||||
|
- For each pull-selected item: include only if the PatchTree node had `defaultSelection ~= nil`
|
||||||
|
- Always send the write request if there are either pull changes OR stage IDs (currently only sent when pullPatch is non-empty -- expand the condition to also fire when `stage_ids` is non-empty)
|
||||||
|
|
||||||
|
If not in a git repo (`repo_root` is `None`), all stage calls are no-ops.
|
||||||
|
|
||||||
|
## Plugin Side (Lua)
|
||||||
|
|
||||||
|
### 6. Add SHA1.luau to plugin
|
||||||
|
|
||||||
|
Copy the user-provided [SHA1.luau](c:\Users\Joe\Downloads\SHA1.luau) to `plugin/src/SHA1.luau`. This is a native-optimized SHA1 implementation that takes a `buffer` and returns a 40-char hex string.
|
||||||
|
|
||||||
|
### 7. Update `PatchTree.build()` in [plugin/src/PatchTree.lua](plugin/src/PatchTree.lua)
|
||||||
|
|
||||||
|
Add a 4th parameter `gitMetadata` (the `gitMetadata` table from `serverInfo`, may be nil).
|
||||||
|
|
||||||
|
At the start of `build()`, convert `changedIds` array to a Set for O(1) lookup:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local changedIdSet = nil
|
||||||
|
if gitMetadata and gitMetadata.changedIds then
|
||||||
|
changedIdSet = {}
|
||||||
|
for _, id in gitMetadata.changedIds do
|
||||||
|
changedIdSet[id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Updated** nodes (around line 308-318), replace `defaultSelection = nil` with:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key**: `script.Source` returns the non-draft (published) version when Script Drafts are enabled. Both sides use the git blob format `"blob <byte_length>\0<content>"` as SHA1 input. No normalization -- committed content is LF (repo standard), Studio Source is LF.
|
||||||
|
|
||||||
|
**Added** and **Removed** nodes: keep `defaultSelection = nil` (unchanged).
|
||||||
|
|
||||||
|
### 8. Update `PatchTree.build()` call sites in [plugin/src/App/init.lua](plugin/src/App/init.lua)
|
||||||
|
|
||||||
|
Must support both **"Initial"** and **"Always"** confirmation modes. In "Always" mode, every incoming WebSocket patch triggers the confirmation dialog via `__onWebSocketMessage` -> `__userConfirmCallback(instanceMap, patch, self.__serverInfo)`, so `serverInfo.gitMetadata` is available for ongoing patches too.
|
||||||
|
|
||||||
|
**Note**: Per-item push/pull/ignore selections for ongoing WebSocket patches (Always mode) are a pre-existing limitation (see `ServeSession.lua` line 311-314). The git defaults will be set in the PatchTree but the existing "Accept"/"Abort" handling for ongoing patches doesn't yet support them. This is not introduced by this feature.
|
||||||
|
|
||||||
|
Three call sites in `App:startSession()` and one approach to wire them:
|
||||||
|
|
||||||
|
**Cache `serverInfo` in a closure-scoped local** accessible to all callbacks:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local cachedServerInfo = nil
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Line 867** (`setConfirmCallback`, initial + ongoing confirms): Has `serverInfo` directly. Cache it: `cachedServerInfo = serverInfo`. Pass `serverInfo.gitMetadata` to `PatchTree.build`. Works for both Initial and Always modes since `__onWebSocketMessage` passes `self.__serverInfo`.
|
||||||
|
- **Line 918** (`setPatchUpdateCallback`, patches merging during active confirmation): Doesn't receive `serverInfo`. Use cached: `cachedServerInfo and cachedServerInfo.gitMetadata`. This is safe because the confirm callback always fires first (sets `cachedServerInfo`) before any patch updates can arrive.
|
||||||
|
- **Line 659** (precommit hook, auto-accepted patches): For patches that bypass confirmation (e.g., "Never" mode), this builds a tree for the Connected page display. Use `cachedServerInfo and cachedServerInfo.gitMetadata`. Will be `nil` if no confirmation has happened yet (first connect stores it).
|
||||||
|
|
||||||
|
### 9. Import SHA1 in PatchTree.lua
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local SHA1 = require(Plugin.SHA1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Self-computed SHA1 with git blob format scheme**: Read content via `git show HEAD:<path>` and `git show :0:<path>` (staged), compute SHA1 using `"blob <size>\0<content>"`. Send both hashes (HEAD + staged if different) so the plugin can match against either version.
|
||||||
|
- **Two-phase tree lock**: Snapshot path mappings under brief lock, then release before running git subprocesses. Prevents blocking all API requests during I/O-bound git operations.
|
||||||
|
- **Auto-staging with asymmetric rules**: Push-accepted items are always staged (filesystem changes finalized). Pull-accepted items are only staged if auto-selected (manually-chosen pulls left unstaged for manual diff). Plugin sends `stage_ids` in the WriteRequest; server resolves to paths and runs `git add`.
|
||||||
|
- **Split staging responsibility**: api.rs stages additions, removals, and push files directly. change_processor stages Source writes after they complete (via `stage_ids` on PatchSet). This avoids staging old content before async writes finish.
|
||||||
|
- **Only Updated items get defaults**: Added (would destructively delete files if "pull") and Removed (IDs aren't in server tree) are left as `nil`
|
||||||
|
- **Scripts-only hash comparison**: Non-script property changes are deferred (see note below)
|
||||||
|
- **Git via subprocess**: No git2/libgit2 dependency, just `std::process::Command`. Graceful fallback if git not installed or not a repo. Per-file `git show` calls are acceptable for typical changed file counts; `git cat-file --batch` is a future optimization for large changesets.
|
||||||
|
- **Draft handling**: `script.Source` returns the non-draft version when Script Drafts are enabled, which is exactly what we compare against the committed version
|
||||||
|
- **Fresh computation per connect**: Not cached -- each `GET /api/rojo` recomputes git state
|
||||||
|
- **No normalization anywhere**: Committed content is LF (repo standard), Studio Source is LF, VFS normalizes reads. Both sides hash identical LF content.
|
||||||
|
- **Init-style scripts (init.luau) handled correctly**: `dir.rs` registers all init file paths (init.luau, init.server.luau, etc.) in `relevant_paths`, so `path_to_ids` maps `MyFolder/init.luau` to the correct Ref. `instigating_source` is the actual init file path (not the directory), so Source writes and git-add target the correct file. Git reports file-level changes (`MyFolder/init.luau`), matching the indexed path.
|
||||||
|
- **No-HEAD edge case**: If the repo has no commits (`git diff HEAD` fails), treat as "no git metadata" and return None. All defaults remain nil.
|
||||||
|
|
||||||
|
## Deferred: Non-Script (Attribute/Property/Tag) Hash Comparison
|
||||||
|
|
||||||
|
Hash comparison for non-script changes (properties, attributes, tags) is **not included in this feature** and would be significantly harder:
|
||||||
|
|
||||||
|
- Would require building a **deterministic payload** of the instance's properties/attributes/tags (possibly msgpack with guaranteed key ordering, including default values) to hash against the committed file content (meta.json5, model.json5, etc.)
|
||||||
|
- Even with a deterministic serialization, there is **no "draft" concept** for non-script properties in Studio. `script.Source` has the draft/non-draft distinction, but property values are always live. There's no way to get "the version before the user started editing" for properties.
|
||||||
|
- This makes the comparison fundamentally less useful: if a user drags a Part in Studio, the property is immediately the new value with no way to recover the "original" for comparison.
|
||||||
|
- For now, non-script changes that are git-changed simply get `defaultSelection = nil` (user must decide).
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
globs: src/web/**/*.rs,src/serve_session.rs,plugin/src/ServeSession.lua,plugin/src/ChangeBatcher/**/*.lua,plugin/src/ApiContext.lua
|
globs: src/web/**,src/serve_session.rs,src/git.rs,,plugin/src/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -63,10 +63,22 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem
|
||||||
"visibleServices": ["Workspace", "ServerScriptService"],
|
"visibleServices": ["Workspace", "ServerScriptService"],
|
||||||
"placeId": 123456,
|
"placeId": 123456,
|
||||||
"gameId": 789012,
|
"gameId": 789012,
|
||||||
"ignoreHiddenServices": true
|
"ignoreHiddenServices": true,
|
||||||
|
"gitMetadata": {
|
||||||
|
"changedIds": ["ref-1", "ref-2"],
|
||||||
|
"scriptCommittedHashes": {
|
||||||
|
"ref-1": ["a1b2c3...40-char-sha1-hex", "d4e5f6...staged-sha1"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`gitMetadata`** is `null` when the project is not in a git repo. Contains:
|
||||||
|
- `changedIds`: Instance Refs whose backing files have changes relative to HEAD (staged + unstaged + untracked)
|
||||||
|
- `scriptCommittedHashes`: For changed script instances, 1-2 SHA1 hashes (HEAD version and staged version if different) computed using the git blob format (`"blob <size>\0<content>"`). Plugin checks if Studio `script.Source` matches ANY hash to default "Atlas".
|
||||||
|
|
||||||
|
Recomputed fresh on each `GET /api/rojo` connect. Uses a two-phase tree lock: snapshot path mappings briefly, then release before running git subprocesses.
|
||||||
|
|
||||||
### Write Request (`POST /api/write`)
|
### Write Request (`POST /api/write`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -74,10 +86,13 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem
|
||||||
"sessionId": "uuid",
|
"sessionId": "uuid",
|
||||||
"removed": ["id-1"],
|
"removed": ["id-1"],
|
||||||
"added": { "id-2": { "parent": "parent-id", "className": "Folder", "name": "New", "properties": {}, "children": [] } },
|
"added": { "id-2": { "parent": "parent-id", "className": "Folder", "name": "New", "properties": {}, "children": [] } },
|
||||||
"updated": [{ "id": "id-3", "changedName": "Renamed", "changedClassName": "Script", "changedProperties": { "Source": "print('hi')" }, "changedMetadata": {} }]
|
"updated": [{ "id": "id-3", "changedName": "Renamed", "changedClassName": "Script", "changedProperties": { "Source": "print('hi')" }, "changedMetadata": {} }],
|
||||||
|
"stageIds": ["ref-1", "ref-2"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`stageIds`**: Instance IDs whose backing files should be staged via `git add` after writing. Includes all push-accepted IDs + auto-selected pull-accepted IDs. The server resolves these to filesystem paths and runs `git add`. Staging is split: `api.rs` stages additions/removals/push files directly; `change_processor` stages Source writes after they complete (via `stage_ids` on PatchSet).
|
||||||
|
|
||||||
**Format detection:** `Name/init.luau` → directory | `Name.luau` → file | neither → use `has_children` (`.lua` also supported)
|
**Format detection:** `Name/init.luau` → directory | `Name.luau` → file | neither → use `has_children` (`.lua` also supported)
|
||||||
|
|
||||||
## WebSocket Protocol (v6)
|
## WebSocket Protocol (v6)
|
||||||
|
|
@ -603,6 +618,25 @@ end
|
||||||
- Safe for production environments
|
- Safe for production environments
|
||||||
- Team viewing same project simultaneously
|
- Team viewing same project simultaneously
|
||||||
|
|
||||||
|
### Git-Based Sync Direction Defaults
|
||||||
|
|
||||||
|
When the plugin shows the sync confirmation dialog, items get smart default selections based on git status:
|
||||||
|
|
||||||
|
- **File has no git changes**: default to "Studio" (pull) -- any diff is because Studio was changed
|
||||||
|
- **File has git changes AND is a script AND Studio Source matches committed/staged hash**: default to "Atlas" (push) -- developer changed the file, Studio hasn't diverged
|
||||||
|
- **Otherwise**: `nil` -- user must decide (both sides changed, or non-script with changes)
|
||||||
|
- **No git repo**: all defaults remain `nil` (feature is no-op)
|
||||||
|
|
||||||
|
Only **Updated** items get defaults. Added/Removed items remain `nil`.
|
||||||
|
|
||||||
|
**Hash computation**: Both sides use `SHA1("blob <byte_len>\0<content>")` (git blob format). Server sends up to 2 hashes per changed script (HEAD + staged if different). Plugin computes hash of `script.Source` (non-draft version) and checks if ANY hash matches.
|
||||||
|
|
||||||
|
**Auto-staging**: After confirmation, accepted files are staged via `git add`:
|
||||||
|
- Push-accepted items: always staged (all types, including non-scripts)
|
||||||
|
- Pull-accepted items: only staged if auto-selected (`defaultSelection ~= nil`); manually-chosen pulls are left unstaged for manual diff
|
||||||
|
|
||||||
|
**Implementation files**: `src/git.rs` (server git helpers), `plugin/src/PatchTree.lua` (default selection logic), `plugin/src/SHA1.luau` (hash computation), `src/web/interface.rs` (`GitMetadata` struct, `stage_ids` on WriteRequest/PatchSet)
|
||||||
|
|
||||||
### Known Limitations (Detailed)
|
### Known Limitations (Detailed)
|
||||||
|
|
||||||
| Issue | Cause | Impact |
|
| Issue | Cause | Impact |
|
||||||
|
|
@ -671,3 +705,4 @@ Browser: `http://localhost:34872` → Network tab → WebSocket messages
|
||||||
| `src/serve_session.rs` - Session | `plugin/src/ChangeBatcher/` - Two-way sync |
|
| `src/serve_session.rs` - Session | `plugin/src/ChangeBatcher/` - Two-way sync |
|
||||||
| `src/change_processor.rs` - FS writer | `plugin/src/Reconciler/` - Patch application |
|
| `src/change_processor.rs` - FS writer | `plugin/src/Reconciler/` - Patch application |
|
||||||
| `src/rojo_ref.rs` - Ref paths | `plugin/src/Reconciler/matching.lua` - Matching |
|
| `src/rojo_ref.rs` - Ref paths | `plugin/src/Reconciler/matching.lua` - Matching |
|
||||||
|
| `src/git.rs` - Git integration | `plugin/src/PatchTree.lua` - Default selections |
|
||||||
|
|
|
||||||
338
.cursor/rules/atlas-plugin.mdc
Normal file
338
.cursor/rules/atlas-plugin.mdc
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
---
|
||||||
|
globs: plugin/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rojo Studio Plugin
|
||||||
|
|
||||||
|
Built with Roact (React-like), handles WebSocket sync with server, patch application to Studio, and UI.
|
||||||
|
|
||||||
|
**Entry:** `plugin/src/init.server.lua` | **Protocol:** WebSocket v6 | **Must match server MAJOR.MINOR**
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
**1. UI Layer** (`App/`)
|
||||||
|
- **init.lua** - Root component, manages connection state, auto-reconnect, sync reminders, playtest server auto-connect
|
||||||
|
- **Theme.lua** - Studio-matched color palette (auto light/dark)
|
||||||
|
- **Page.lua** - Page wrapper component
|
||||||
|
- **Components/** - Reusable UI components
|
||||||
|
- `PatchVisualizer/` - Tree view of changes with property diffs (`init.lua`, `DomLabel.lua`, `DisplayValue.lua`, `ChangeList.lua`)
|
||||||
|
- `StringDiffVisualizer/` - Side-by-side script diffs (`init.lua`, `StringDiff.lua`)
|
||||||
|
- `TableDiffVisualizer/` - Attributes and Tags diffs (`init.lua`, `Dictionary.lua`, `Array.lua`)
|
||||||
|
- `Notifications/` - Toast notifications (`init.lua`, `Notification.lua`, `FullscreenNotification.lua`)
|
||||||
|
- `Studio/` - Studio API wrappers (`StudioPluginAction`, `StudioPluginContext`, `StudioPluginGui`, `StudioToggleButton`, `StudioToolbarContext`, `StudioToolbar`)
|
||||||
|
- General: `Header`, `TextInput`, `TextButton`, `Checkbox`, `Dropdown`, `Tag`, `IconButton`, `Tooltip`, `TouchRipple`, `VirtualScroller`, `ScrollingFrame`, `Spinner`, `BorderedContainer`, `EditableImage`, `SlicedImage`, `ClassIcon`
|
||||||
|
- **StatusPages/** - Main UI pages
|
||||||
|
- `NotConnected.lua` - Connection form (host/port input)
|
||||||
|
- `Connecting.lua` - Loading screen
|
||||||
|
- `Confirming.lua` - Patch confirmation dialog with push/pull/ignore per-node selections
|
||||||
|
- `Connected.lua` - Status display with patch history
|
||||||
|
- `Error.lua` - Error display
|
||||||
|
- `Settings/` - Settings UI (`init.lua`, `Setting.lua`)
|
||||||
|
- Uses Roact for declarative UI
|
||||||
|
|
||||||
|
**2. Communication** (`ServeSession.lua`, `ApiContext.lua`)
|
||||||
|
- WebSocket connection to server via `/api/socket/:cursor`
|
||||||
|
- Handles patches, errors, reconnection
|
||||||
|
- Protocol version checking
|
||||||
|
- Confirmation flow with push/pull/ignore
|
||||||
|
- One-shot mode support
|
||||||
|
|
||||||
|
**3. Reconciler** (`Reconciler/`)
|
||||||
|
- **applyPatch.lua** - Applies server patches to Studio tree
|
||||||
|
- **reify.lua** - Creates instances from snapshots (parent last)
|
||||||
|
- **hydrate.lua** - Updates InstanceMap with existing Studio instances
|
||||||
|
- **diff.lua** - Computes patch between virtual and Studio state (for two-way sync)
|
||||||
|
- **matching.lua** - Recursive change-count scoring algorithm for pairing virtual instances with Studio instances
|
||||||
|
- **trueEquals.lua** - Shared value equality (fuzzy floats, Color3, CFrame, Vector3; NaN handling; nil/null-ref equivalence)
|
||||||
|
- **decodeValue.lua** - Converts encoded values to Roblox types
|
||||||
|
- **setProperty.lua** - Sets properties safely (handles unscriptable)
|
||||||
|
- **getProperty.lua** - Gets properties with fallbacks
|
||||||
|
- **Error.lua** - Reconciler error handling
|
||||||
|
|
||||||
|
**4. ChangeBatcher** (`ChangeBatcher/`)
|
||||||
|
- **init.lua** - Main batcher: accumulates changes every 200ms, pause/resume, deferred Ref resolution
|
||||||
|
- **createPatchSet.lua** - Converts pending changes to PatchSet
|
||||||
|
- **encodePatchUpdate.lua** - Encodes property updates including Ref properties via InstanceMap
|
||||||
|
- **encodeInstance.lua** - Full instance encoding (for additions/syncback)
|
||||||
|
- **encodeProperty.lua** - Single property value encoding via RbxDom
|
||||||
|
- **propertyFilter.lua** - Property type filtering (only `UniqueId` blocked; `Ref` is allowed)
|
||||||
|
|
||||||
|
**5. State Management**
|
||||||
|
- **Settings.lua** - Persistent plugin settings (prefixed `Atlas_` in plugin store)
|
||||||
|
- **InstanceMap.lua** - Bidirectional Server ID <-> Studio Instance mapping with change detection
|
||||||
|
- **PatchSet.lua** / **PatchTree.lua** - Patch representation and visualization tree (PatchTree.build accepts optional `gitMetadata` for smart default selections)
|
||||||
|
- **Types.lua** - Type definitions and runtime validation
|
||||||
|
- **ChangeMetadata.lua** - Change metadata tracking
|
||||||
|
|
||||||
|
**6. Utilities**
|
||||||
|
- **Timer.lua** - Performance timing
|
||||||
|
- **DiffUtil.lua** - Diff utilities
|
||||||
|
- **Version.lua** - Version parsing and comparison
|
||||||
|
- **Dictionary.lua** - Dictionary utilities
|
||||||
|
- **Assets.lua** - Asset references
|
||||||
|
- **SHA1.luau** - SHA1 hashing (used for git blob format hash comparison in PatchTree default selections)
|
||||||
|
- **XXH32.luau** - XXH32 hash function
|
||||||
|
- **soundPlayer.lua** - Sound effects
|
||||||
|
- **preloadAssets.lua** - Asset preloading
|
||||||
|
- **ignorePlaceIds.lua** - Place ID ignore list
|
||||||
|
- **timeUtil.lua** - Time formatting
|
||||||
|
- **strict.lua** - Strict module wrapper
|
||||||
|
- **invariant.lua** - Assertion utilities
|
||||||
|
|
||||||
|
**7. Testing**
|
||||||
|
- Unit tests: `.spec.lua` files (TestEZ) throughout all modules
|
||||||
|
- Integration tests: `integration/` directory (`confirmBehavior`, `oneShotSync`, `syncflow`, `twoWaySync`)
|
||||||
|
- Stress tests: `.stress.spec.lua` files (applyPatch, diff, reify, encodeInstance, PatchSet)
|
||||||
|
- Chaos tests: `chaos/` directory (`stressPatterns.spec.lua`)
|
||||||
|
- Test utilities: `testUtils/` (`MockApiContext`, `PatchGenerator`, `LargeTreeGenerator`)
|
||||||
|
- Test runner: `runTests.lua`
|
||||||
|
|
||||||
|
## Build Structure (`plugin.project.json`)
|
||||||
|
|
||||||
|
The plugin is built from `plugin.project.json` at the repo root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Rojo",
|
||||||
|
"tree": {
|
||||||
|
"$className": "Folder",
|
||||||
|
"Plugin": { "$path": "plugin/src" },
|
||||||
|
"Packages": {
|
||||||
|
"$path": "plugin/Packages",
|
||||||
|
"Log": { "$path": "plugin/log" },
|
||||||
|
"Http": { "$path": "plugin/http" },
|
||||||
|
"Fmt": { "$path": "plugin/fmt" },
|
||||||
|
"RbxDom": { "$path": "plugin/rbx_dom_lua" }
|
||||||
|
},
|
||||||
|
"Version": { "$path": "plugin/Version.txt" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vendored packages live under `plugin/Packages/`. Custom modules (`Log`, `Http`, `Fmt`, `RbxDom`) are injected into the Packages tree from their own directories at build time.
|
||||||
|
|
||||||
|
## Key Configuration (`Config.lua`)
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
isDevBuild = false, -- Detected from ROJO_DEV_BUILD instance
|
||||||
|
codename = "Epiphany",
|
||||||
|
version = { major, minor, patch, metadata }, -- Parsed from Version.txt
|
||||||
|
expectedServerVersionString = "7.7 or newer",
|
||||||
|
protocolVersion = 6, -- MUST match server
|
||||||
|
defaultHost = "localhost",
|
||||||
|
defaultPort = "34873",
|
||||||
|
ignoredClassNames = { -- Auto-created instances to skip
|
||||||
|
TouchTransmitter = true,
|
||||||
|
Camera = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When changing protocol:** Bump `protocolVersion` in both `plugin/src/Config.lua` AND `src/web/interface.rs`.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Persistent settings stored with `Atlas_` prefix in Studio plugin store:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
openScriptsExternally = false,
|
||||||
|
twoWaySync = true,
|
||||||
|
oneShotSync = true,
|
||||||
|
autoReconnect = false,
|
||||||
|
showNotifications = false,
|
||||||
|
enableSyncFallback = false,
|
||||||
|
syncReminderMode = "Notify", -- "None" | "Notify" | "Fullscreen"
|
||||||
|
syncReminderPolling = true,
|
||||||
|
checkForUpdates = false,
|
||||||
|
checkForPrereleases = false,
|
||||||
|
autoConnectPlaytestServer = false,
|
||||||
|
confirmationBehavior = "Always", -- "Never" | "Initial" | "Always" | "Large Changes" | "Unlisted PlaceId"
|
||||||
|
largeChangesConfirmationThreshold = 5,
|
||||||
|
playSounds = true,
|
||||||
|
typecheckingEnabled = false,
|
||||||
|
logLevel = "Info",
|
||||||
|
timingLogsEnabled = false,
|
||||||
|
priorEndpoints = {},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Protocol (v6)
|
||||||
|
|
||||||
|
1. Connect to `ws://host:port/api/socket/:cursor`
|
||||||
|
2. Receive `ServerInfo` handshake (protocol version, project name, session ID)
|
||||||
|
3. Plugin sends: `{type = "Subscribe"}`
|
||||||
|
4. Server sends patches on filesystem changes (MessagePack encoded)
|
||||||
|
|
||||||
|
**Patch structure:**
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
type = "ApplyPatch",
|
||||||
|
sessionId = "uuid",
|
||||||
|
patch = {
|
||||||
|
removed = {"id-1", "id-2"},
|
||||||
|
added = {
|
||||||
|
["id-3"] = {
|
||||||
|
id = "id-3", className = "Folder", name = "New",
|
||||||
|
properties = {}, metadata = { ignoreUnknownInstances = false },
|
||||||
|
children = {"child-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updated = {
|
||||||
|
{ id = "id-4", changedName = "Renamed", changedProperties = { Source = "code" } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ServeSession (`ServeSession.lua`)
|
||||||
|
|
||||||
|
Manages the lifecycle of a connection to the server.
|
||||||
|
|
||||||
|
**Status flow:** `NotStarted` -> `Connecting` -> `Connected` -> `Disconnected`
|
||||||
|
|
||||||
|
**Key methods:**
|
||||||
|
- `new(options)` - Creates session with `ApiContext` and `twoWaySync` flag
|
||||||
|
- `start()` - Connects, computes initial patch, shows confirmation
|
||||||
|
- `stop()` - Disconnects and cleans up
|
||||||
|
- `__onWebSocketMessage()` - Handles incoming patches
|
||||||
|
- `__applyPatch()` - Applies patches via Reconciler
|
||||||
|
- `__confirmAndApplyInitialPatch()` - Handles user confirmation with push/pull/ignore
|
||||||
|
- `__computeInitialPatch()` - Computes catch-up patch
|
||||||
|
- `hookPrecommit()` / `hookPostcommit()` - Lifecycle hooks
|
||||||
|
|
||||||
|
**Confirmation flow:**
|
||||||
|
|
||||||
|
1. Receive patch (initial or streaming)
|
||||||
|
2. Check `confirmationBehavior` setting
|
||||||
|
3. If confirmation needed: pause ChangeBatcher, build PatchTree with git-based default selections (from `serverInfo.gitMetadata`), show Confirming page with PatchVisualizer
|
||||||
|
4. User reviews defaults and selects push/pull/ignore per node
|
||||||
|
5. Build `stageIds` list (all push-accepted + auto-selected pull-accepted), send with write request
|
||||||
|
6. Apply via Reconciler, resume ChangeBatcher
|
||||||
|
|
||||||
|
**One-shot mode (`oneShotSync: true`):**
|
||||||
|
- ChangeBatcher still collects changes (for diff visualization)
|
||||||
|
- Automatic outgoing writes are blocked
|
||||||
|
- Only explicit "pull" selections in confirmation dialog can write
|
||||||
|
- Disconnects after initial sync completes
|
||||||
|
|
||||||
|
## Reconciler (`Reconciler/`)
|
||||||
|
|
||||||
|
**applyPatch.lua** - Main patch application:
|
||||||
|
1. Remove instances in `removed`
|
||||||
|
2. Create instances in `added` via `reify`
|
||||||
|
3. Update instances in `updated` via `hydrate`
|
||||||
|
4. Handle deferred Ref properties
|
||||||
|
5. Collect errors, return unapplied patch
|
||||||
|
|
||||||
|
**reify.lua** - Create instances (parent last - Roblox best practice)
|
||||||
|
**hydrate.lua** - Update InstanceMap with existing instances (uses matching algorithm)
|
||||||
|
**diff.lua** - Compute patch between virtual instances and Studio state
|
||||||
|
**setProperty.lua** / **getProperty.lua** - Handle unscriptable properties
|
||||||
|
|
||||||
|
### Matching Algorithm (`matching.lua`)
|
||||||
|
|
||||||
|
Pairs incoming virtual instances with existing Studio instances during hydration.
|
||||||
|
|
||||||
|
**Entry point:** `Matching.matchChildren(virtualChildren, studioChildren, virtualInstances)`
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
1. **Fast path:** Group by (Name, ClassName). 1:1 groups get instant-matched with zero scoring.
|
||||||
|
2. **Ambiguous groups:** For groups with multiple candidates, compute pairwise `_computeChangeCount()` (recursive, scores entire subtree). Greedy assignment sorted by cost ascending, child-order tiebreaker for stability.
|
||||||
|
3. **Leftovers:** Unmatched virtual = new instances, unmatched studio = deleted.
|
||||||
|
|
||||||
|
**Sort stability note:** `table.sort` is NOT stable in Luau. Comparator breaks ties by insertion index to ensure deterministic ordering.
|
||||||
|
|
||||||
|
**Value equality:** Shared `trueEquals.lua` module used by both `matching.lua` and `diff.lua` -- fuzzy equality for floats (epsilon 0.0001), Color3 via RGB ints, CFrame/Vector3 component-wise, NaN handling, nil/null-ref equivalence.
|
||||||
|
|
||||||
|
## InstanceMap (`InstanceMap.lua`)
|
||||||
|
|
||||||
|
Bidirectional Server ID <-> Studio Instance mapping with integrated change detection:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
InstanceMap = {
|
||||||
|
fromIds = {}, -- Server ID -> Studio Instance
|
||||||
|
fromInstances = {}, -- Studio Instance -> Server ID
|
||||||
|
pausedUpdateInstances = {}, -- Set of instances to ignore changes from
|
||||||
|
instancesToSignal = {}, -- Instance -> RBXScriptConnection(s)
|
||||||
|
onInstanceChanged = fn, -- Callback: (instance, propertyName) -> void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key methods:**
|
||||||
|
- `insert(id, instance)` - Add mapping and connect change signals
|
||||||
|
- `removeId(id)` / `removeInstance(instance)` - Remove mapping and disconnect signals
|
||||||
|
- `destroyInstance(instance)` - Parent to nil (preserves undo history)
|
||||||
|
- `fromId(id)` / `fromInstance(instance)` - Look up mapping
|
||||||
|
- `pauseInstance(instance)` / `unpauseAllInstances()` - Pause/resume change detection
|
||||||
|
|
||||||
|
**Signal connection (`__connectSignals`):**
|
||||||
|
- Regular instances: `instance.Changed` event (fires for any property)
|
||||||
|
- ValueBase instances: Explicit `GetPropertyChangedSignal` for Name, Value, Parent (ValueBase.Changed only fires for Value)
|
||||||
|
|
||||||
|
**Change guards (`__maybeFireInstanceChanged`):**
|
||||||
|
- Skipped if instance is in `pausedUpdateInstances`
|
||||||
|
- Skipped if `onInstanceChanged` callback is nil
|
||||||
|
- Skipped if `RunService:IsRunning()` (Play mode)
|
||||||
|
|
||||||
|
**Pause mechanism:** During incoming patch application, instances are paused to prevent their changes from being sent back to the server (feedback loop prevention). `unpauseAllInstances()` is called each frame in `ChangeBatcher:__cycle()`.
|
||||||
|
|
||||||
|
## Two-Way Sync
|
||||||
|
|
||||||
|
Watches Studio changes and sends them to the server in real-time. Enabled by default (`twoWaySync: true`).
|
||||||
|
|
||||||
|
**Data flow:** Studio property change -> InstanceMap signal -> ServeSession callback -> ChangeBatcher -> 200ms batch flush -> `POST /api/write` (MessagePack) -> Server writes to filesystem
|
||||||
|
|
||||||
|
**ChangeBatcher state:**
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
__accumulator = 0, -- Time since last flush
|
||||||
|
__pendingPropertyChanges = {}, -- Instance -> { PropertyName -> true }
|
||||||
|
__pendingUnresolvedRefs = {}, -- targetInstance -> [{sourceInstance, propertyName}]
|
||||||
|
__syncSourceOnly = false, -- Only sync Source property
|
||||||
|
__paused = false, -- Pause during confirmation
|
||||||
|
__renderSteppedConnection = conn, -- Frame loop connection
|
||||||
|
__instanceMap = instanceMap,
|
||||||
|
__onChangesFlushed = callback,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deferred Ref resolution:** When a Ref property targets an instance not yet in the InstanceMap, the property is deferred in `__pendingUnresolvedRefs`. When the target appears via `onInstanceInserted`, the property change is re-queued for the next batch.
|
||||||
|
|
||||||
|
**Guards:**
|
||||||
|
- `oneShotSync` setting blocks automatic outgoing writes
|
||||||
|
- `__paused` flag blocks flushes during confirmation dialog
|
||||||
|
- `RunService:IsRunning()` blocks during Play mode
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Vendored Packages (`plugin/Packages/`)
|
||||||
|
|
||||||
|
Roact, Flipper, Promise, TestEZ, t, Highlighter, msgpack-luau, json5. **Don't edit vendored packages.**
|
||||||
|
|
||||||
|
### Custom Modules (injected via `plugin.project.json`)
|
||||||
|
|
||||||
|
| Module | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Log | `plugin/log` | Structured logging with configurable levels |
|
||||||
|
| Http | `plugin/http` | HTTP client utilities |
|
||||||
|
| Fmt | `plugin/fmt` | String formatting with debug output |
|
||||||
|
| RbxDom | `plugin/rbx_dom_lua` | Reflection database, property descriptors, encoded value encode/decode |
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
**Plugin not reloading:** Enable Studio setting "Reload plugins on file change"
|
||||||
|
**Connection fails:** Check server running, host/port match, protocol versions match
|
||||||
|
**Properties not syncing:** Verify scriptable in rbx_reflection_database
|
||||||
|
**Duplicates:** InstanceMap corruption - disconnect, restart Studio
|
||||||
|
**Two-way sync feedback loops:** Check pause mechanism is working; instances must be paused during reconciliation
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
Settings -> Log Level -> "Trace" for verbose output. `ROJO_DEV_BUILD` instance enables auto-tests via TestEZ.
|
||||||
|
|
||||||
|
**Timing:** Enable `timingLogsEnabled` setting to see `Timer.start`/`Timer.stop` measurements for Reconciler and other hot paths.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
globs: rbx-dom/**/*.rs,rbx-dom/**/*.lua,rbx-dom/**/*.toml
|
globs: rbx-dom/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
globs: src/**/*.rs,crates/**/*.rs
|
globs: src/**,crates/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -380,6 +380,7 @@ src/
|
||||||
meta.rs - Meta file helpers
|
meta.rs - Meta file helpers
|
||||||
web/ - HTTP/WebSocket
|
web/ - HTTP/WebSocket
|
||||||
change_processor.rs - Two-way sync filesystem writer
|
change_processor.rs - Two-way sync filesystem writer
|
||||||
|
git.rs - Git integration (changed files, SHA1 hashing, auto-staging)
|
||||||
rojo_ref.rs - Ref path system (Rojo_Ref_*, RefPathIndex)
|
rojo_ref.rs - Ref path system (Rojo_Ref_*, RefPathIndex)
|
||||||
variant_eq.rs - Property value comparison
|
variant_eq.rs - Property value comparison
|
||||||
lib.rs - Public API
|
lib.rs - Public API
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
globs: src/syncback/**/*.rs,src/cli/syncback.rs
|
globs: src/syncback/**,src/cli/syncback.rs
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,7 @@ src/ - Rust server/CLI
|
||||||
meta.rs - Meta file read/write helpers
|
meta.rs - Meta file read/write helpers
|
||||||
web/ - HTTP/WebSocket server
|
web/ - HTTP/WebSocket server
|
||||||
change_processor.rs - Two-way sync filesystem writer
|
change_processor.rs - Two-way sync filesystem writer
|
||||||
|
git.rs - Git integration (changed files, SHA1 hashing, auto-staging)
|
||||||
rojo_ref.rs - Ref path system (Rojo_Ref_*, RefPathIndex)
|
rojo_ref.rs - Ref path system (Rojo_Ref_*, RefPathIndex)
|
||||||
variant_eq.rs - Property value comparison
|
variant_eq.rs - Property value comparison
|
||||||
|
|
||||||
|
|
@ -333,6 +334,8 @@ plugin/src/ - Lua Studio plugin
|
||||||
trueEquals.lua - Shared value equality module
|
trueEquals.lua - Shared value equality module
|
||||||
ChangeBatcher/ - Two-way sync patch creation
|
ChangeBatcher/ - Two-way sync patch creation
|
||||||
Config.lua - Version and protocol config
|
Config.lua - Version and protocol config
|
||||||
|
PatchTree.lua - Patch visualization tree (git-based default selections)
|
||||||
|
SHA1.luau - SHA1 hash for git blob format comparison
|
||||||
XXH32.luau - Hash function for plugin
|
XXH32.luau - Hash function for plugin
|
||||||
|
|
||||||
rbx-dom/ - Git submodule (separate repo)
|
rbx-dom/ - Git submodule (separate repo)
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
---
|
|
||||||
globs: plugin/**/*.lua,plugin/**/*.luau,plugin/**/*.json
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Rojo Studio Plugin
|
|
||||||
|
|
||||||
Built with Roact (React-like), handles WebSocket sync with server, patch application to Studio, and UI.
|
|
||||||
|
|
||||||
**Entry:** `plugin/src/init.server.lua` | **Protocol:** WebSocket v5 | **Must match server MAJOR.MINOR**
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
**1. UI Layer** (`App/`)
|
|
||||||
- **App.lua** - Root component, manages connection state
|
|
||||||
- **Components/** - Reusable UI components
|
|
||||||
- `StatusPages/` - Connected, Connecting, Error, Settings screens
|
|
||||||
- `PatchVisualizer/` - Shows changes before applying
|
|
||||||
- `StringDiffVisualizer/` - Side-by-side script diffs
|
|
||||||
- `TableDiffVisualizer/` - Attributes and Tags diffs
|
|
||||||
- `Notifications/` - Toast notifications
|
|
||||||
- **Theme.lua** - Studio-matched color palette
|
|
||||||
- Uses Roact for declarative UI
|
|
||||||
|
|
||||||
**2. Communication** (`ServeSession.lua`)
|
|
||||||
- WebSocket connection to server (previously long-polling)
|
|
||||||
- Handles patches, errors, reconnection
|
|
||||||
- Protocol version checking
|
|
||||||
|
|
||||||
**3. Reconciler** (`Reconciler/`)
|
|
||||||
- **applyPatch.lua** - Applies server patches to Studio tree
|
|
||||||
- **reify.lua** - Creates instances from snapshots
|
|
||||||
- **hydrate.lua** - Updates existing instances
|
|
||||||
- **diff.lua** - Generates patches (for two-way sync)
|
|
||||||
- **decodeValue.lua** - Converts encoded values to Roblox types
|
|
||||||
- **setProperty.lua** - Sets properties safely
|
|
||||||
- **getProperty.lua** - Gets properties with fallbacks
|
|
||||||
|
|
||||||
**4. State Management**
|
|
||||||
- **Settings.lua** - Persistent plugin settings
|
|
||||||
- **InstanceMap.lua** - Tracks server ID → Studio instance mapping
|
|
||||||
- **PatchSet.lua** / **PatchTree.lua** - Patch representation
|
|
||||||
|
|
||||||
**5. Testing**
|
|
||||||
- Unit tests: `.spec.lua` files (TestEZ)
|
|
||||||
- Integration tests: `integration/` directory
|
|
||||||
- Stress tests: `.stress.spec.lua` files
|
|
||||||
- Test runner: `runTests.lua`
|
|
||||||
|
|
||||||
## Key Configuration (`Config.lua`)
|
|
||||||
|
|
||||||
```lua
|
|
||||||
{
|
|
||||||
version = {7, 7, 0, "-rc.1"},
|
|
||||||
protocolVersion = 5, -- MUST match server
|
|
||||||
ignoredClassNames = { TouchTransmitter = true, Camera = true }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**When changing protocol:** Bump `protocolVersion` in both plugin AND server.
|
|
||||||
|
|
||||||
## WebSocket Protocol (v5)
|
|
||||||
|
|
||||||
1. Connect to `ws://host:port/api/rojo`
|
|
||||||
2. Server sends: protocol version, project name
|
|
||||||
3. Plugin sends: `{"type": "Subscribe"}`
|
|
||||||
4. Server sends patches on filesystem changes
|
|
||||||
|
|
||||||
**Patch structure:**
|
|
||||||
```lua
|
|
||||||
{
|
|
||||||
type = "ApplyPatch",
|
|
||||||
sessionId = "uuid",
|
|
||||||
patch = {
|
|
||||||
removed = {"id1", "id2"}, -- Array of IDs (Vec<Ref>)
|
|
||||||
added = { ["id3"] = {...} }, -- Map: ID → Instance data
|
|
||||||
updated = { -- Array of updates (Vec<InstanceUpdate>)
|
|
||||||
{ id = "id4", changedName = "...", changedProperties = {...} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reconciler (`Reconciler/`)
|
|
||||||
|
|
||||||
**applyPatch.lua** - Main patch application:
|
|
||||||
1. Remove instances in `removed`
|
|
||||||
2. Create instances in `added` via `reify`
|
|
||||||
3. Update instances in `updated` via `hydrate`
|
|
||||||
4. Collect errors, show in UI
|
|
||||||
|
|
||||||
**reify.lua** - Create instances (parent last - Roblox best practice)
|
|
||||||
**hydrate.lua** - Update existing (only changed properties)
|
|
||||||
**setProperty.lua** / **getProperty.lua** - Handle unscriptable properties
|
|
||||||
|
|
||||||
## UI Components (`App/`)
|
|
||||||
|
|
||||||
**StatusPages:** NotConnected, Connecting, Connected, Error, Settings
|
|
||||||
**PatchVisualizer:** Tree view of changes with property diffs
|
|
||||||
**StringDiffVisualizer:** Side-by-side script diffs
|
|
||||||
**TableDiffVisualizer:** Attributes and Tags diffs
|
|
||||||
**Notifications:** Toast messages
|
|
||||||
**Theme.lua:** Studio-matched colors (auto light/dark)
|
|
||||||
|
|
||||||
## InstanceMap
|
|
||||||
|
|
||||||
Bidirectional Server ID ↔ Studio Instance mapping:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
instanceMap:insert(serverId, studioInstance)
|
|
||||||
local instance = instanceMap:fromId(serverId)
|
|
||||||
local serverId = instanceMap:fromInstance(instance)
|
|
||||||
```
|
|
||||||
|
|
||||||
Critical for patch application and avoiding duplicates.
|
|
||||||
|
|
||||||
## Two-Way Sync (Experimental)
|
|
||||||
|
|
||||||
⚠️ Watches Studio changes, sends to server. Has known issues - use with caution.
|
|
||||||
|
|
||||||
## Dependencies (`plugin/Packages/`)
|
|
||||||
|
|
||||||
Roact, Flipper, Promise, TestEZ, t, Highlighter, msgpack-luau. **Don't edit vendored packages.**
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
**Plugin not reloading:** Enable Studio setting "Reload plugins on file change"
|
|
||||||
**Connection fails:** Check server running, host/port match, protocol versions match
|
|
||||||
**Properties not syncing:** Verify scriptable in rbx_reflection_database
|
|
||||||
**Duplicates:** InstanceMap corruption - disconnect, restart Studio
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
Settings → Log Level → "Trace" for verbose output. `ROJO_DEV_BUILD` instance enables auto-tests.
|
|
||||||
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -179,7 +179,7 @@ dependencies = [
|
||||||
"bincode",
|
"bincode",
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"clap 4.5.58",
|
"clap 4.5.59",
|
||||||
"criterion",
|
"criterion",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"csv",
|
"csv",
|
||||||
|
|
@ -227,6 +227,7 @@ dependencies = [
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha1 0.10.6",
|
||||||
"strum",
|
"strum",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
|
|
@ -536,9 +537,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.58"
|
version = "4.5.59"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
|
|
@ -546,9 +547,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.58"
|
version = "4.5.59"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
|
@ -710,7 +711,7 @@ dependencies = [
|
||||||
"anes",
|
"anes",
|
||||||
"cast",
|
"cast",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"clap 4.5.58",
|
"clap 4.5.59",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"itertools",
|
"itertools",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
|
@ -3555,9 +3556,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.0.8+spec-1.1.0"
|
version = "1.0.9+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
|
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
@ -3737,9 +3738,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.23"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ data-encoding = "2.10.0"
|
||||||
pathdiff = "0.2.3"
|
pathdiff = "0.2.3"
|
||||||
|
|
||||||
blake3 = "1.8.3"
|
blake3 = "1.8.3"
|
||||||
|
sha1 = "0.10"
|
||||||
float-cmp = "0.10.0"
|
float-cmp = "0.10.0"
|
||||||
indexmap = { version = "2.13.0", features = ["serde"] }
|
indexmap = { version = "2.13.0", features = ["serde"] }
|
||||||
rmp-serde = "1.3.1"
|
rmp-serde = "1.3.1"
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ function ApiContext:read(ids)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ApiContext:write(patch)
|
function ApiContext:write(patch, stageIds)
|
||||||
local url = ("%s/api/write"):format(self.__baseUrl)
|
local url = ("%s/api/write"):format(self.__baseUrl)
|
||||||
|
|
||||||
-- Log what we're sending to the server (syncback/pull)
|
-- Log what we're sending to the server (syncback/pull)
|
||||||
|
|
@ -248,6 +248,7 @@ function ApiContext:write(patch)
|
||||||
removed = patch.removed,
|
removed = patch.removed,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
added = added,
|
added = added,
|
||||||
|
stageIds = stageIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
body = Http.msgpackEncode(body)
|
body = Http.msgpackEncode(body)
|
||||||
|
|
|
||||||
|
|
@ -653,10 +653,12 @@ function App:startSession()
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
local cachedServerInfo = nil
|
||||||
|
|
||||||
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
|
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
|
||||||
-- Build new tree for patch
|
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
|
||||||
self:setState({
|
self:setState({
|
||||||
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
|
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }, gitMetadata),
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
|
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
|
||||||
|
|
@ -790,6 +792,8 @@ function App:startSession()
|
||||||
local initialSyncConfirmed = false
|
local initialSyncConfirmed = false
|
||||||
|
|
||||||
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
|
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
|
||||||
|
cachedServerInfo = serverInfo
|
||||||
|
|
||||||
-- Filter out the DataModel name change from the patch
|
-- Filter out the DataModel name change from the patch
|
||||||
-- The project name (DataModel.Name) is managed by Studio independently
|
-- The project name (DataModel.Name) is managed by Studio independently
|
||||||
PatchSet.removeDataModelName(patch, instanceMap)
|
PatchSet.removeDataModelName(patch, instanceMap)
|
||||||
|
|
@ -864,7 +868,7 @@ function App:startSession()
|
||||||
})
|
})
|
||||||
self:setState({
|
self:setState({
|
||||||
appStatus = AppStatus.Confirming,
|
appStatus = AppStatus.Confirming,
|
||||||
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
|
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, serverInfo.gitMetadata),
|
||||||
confirmData = {
|
confirmData = {
|
||||||
serverInfo = serverInfo,
|
serverInfo = serverInfo,
|
||||||
},
|
},
|
||||||
|
|
@ -914,8 +918,9 @@ function App:startSession()
|
||||||
-- Update the patchTree when new changes arrive during confirmation
|
-- Update the patchTree when new changes arrive during confirmation
|
||||||
-- The changedIds parameter contains IDs of items that were modified
|
-- The changedIds parameter contains IDs of items that were modified
|
||||||
-- so their selections can be reset (user must re-review)
|
-- so their selections can be reset (user must re-review)
|
||||||
|
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
|
||||||
self:setState({
|
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
|
changedIds = changedIds, -- Pass to UI so it can unselect changed items
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
|
|
@ -1045,7 +1050,19 @@ function App:render()
|
||||||
self.confirmationBindable:Fire("Abort")
|
self.confirmationBindable:Fire("Abort")
|
||||||
end,
|
end,
|
||||||
onConfirm = function(selections)
|
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,
|
end,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ local Log = require(Packages.Log)
|
||||||
|
|
||||||
local ChangeMetadata = require(Plugin.ChangeMetadata)
|
local ChangeMetadata = require(Plugin.ChangeMetadata)
|
||||||
local Config = require(Plugin.Config)
|
local Config = require(Plugin.Config)
|
||||||
|
local SHA1 = require(Plugin.SHA1)
|
||||||
local Timer = require(Plugin.Timer)
|
local Timer = require(Plugin.Timer)
|
||||||
local Types = require(Plugin.Types)
|
local Types = require(Plugin.Types)
|
||||||
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
||||||
|
|
@ -212,7 +213,8 @@ local PatchTree = {}
|
||||||
|
|
||||||
-- Builds a new tree from a patch and instanceMap
|
-- Builds a new tree from a patch and instanceMap
|
||||||
-- uses changeListHeaders in node.changeList
|
-- 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")
|
Timer.start("PatchTree.build")
|
||||||
local clock = os.clock()
|
local clock = os.clock()
|
||||||
|
|
||||||
|
|
@ -220,6 +222,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
|
||||||
|
|
||||||
local knownAncestors = {}
|
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")
|
Timer.start("patch.updated")
|
||||||
for _, change in patch.updated do
|
for _, change in patch.updated do
|
||||||
clock = yieldIfNeeded(clock)
|
clock = yieldIfNeeded(clock)
|
||||||
|
|
@ -304,8 +314,28 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
|
||||||
table.insert(changeList, 1, changeListHeaders)
|
table.insert(changeList, 1, changeListHeaders)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add this node to tree
|
-- Compute default selection from git metadata
|
||||||
-- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip
|
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], {
|
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
||||||
id = change.id,
|
id = change.id,
|
||||||
patchType = "Edit",
|
patchType = "Edit",
|
||||||
|
|
@ -314,7 +344,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
|
||||||
instance = instance,
|
instance = instance,
|
||||||
changeInfo = changeInfo,
|
changeInfo = changeInfo,
|
||||||
changeList = changeList,
|
changeList = changeList,
|
||||||
defaultSelection = nil,
|
defaultSelection = defaultSelection,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
Timer.stop()
|
Timer.stop()
|
||||||
|
|
|
||||||
107
plugin/src/SHA1.luau
Normal file
107
plugin/src/SHA1.luau
Normal 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
|
||||||
|
|
@ -695,6 +695,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
elseif type(userDecision) == "table" and userDecision.type == "Confirm" then
|
elseif type(userDecision) == "table" and userDecision.type == "Confirm" then
|
||||||
-- New: Apply based on per-item selections
|
-- New: Apply based on per-item selections
|
||||||
local selections = userDecision.selections or {}
|
local selections = userDecision.selections or {}
|
||||||
|
local autoSelectedIds = userDecision.autoSelectedIds or {}
|
||||||
|
|
||||||
-- Log selection summary (debug level)
|
-- Log selection summary (debug level)
|
||||||
local pushCount, pullCount, ignoreCount = 0, 0, 0
|
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 pushPatch = PatchSet.newEmpty() -- Items to apply to Studio
|
||||||
local pullPatch = PatchSet.newEmpty() -- Items to send back to Rojo
|
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
|
-- Process updated items
|
||||||
for _, change in catchUpPatch.updated do
|
for _, change in catchUpPatch.updated do
|
||||||
local selection = selections[change.id]
|
local selection = selections[change.id]
|
||||||
|
|
@ -723,6 +727,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
-- Apply Rojo changes to Studio
|
-- Apply Rojo changes to Studio
|
||||||
Log.info("[Push] Update: {}", instancePath)
|
Log.info("[Push] Update: {}", instancePath)
|
||||||
table.insert(pushPatch.updated, change)
|
table.insert(pushPatch.updated, change)
|
||||||
|
table.insert(stageIds, change.id)
|
||||||
elseif selection == "pull" and self.__twoWaySync then
|
elseif selection == "pull" and self.__twoWaySync then
|
||||||
-- Send Studio state back to Rojo
|
-- Send Studio state back to Rojo
|
||||||
if instance then
|
if instance then
|
||||||
|
|
@ -749,6 +754,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
)
|
)
|
||||||
if update then
|
if update then
|
||||||
table.insert(pullPatch.updated, update)
|
table.insert(pullPatch.updated, update)
|
||||||
|
if autoSelectedIds[change.id] then
|
||||||
|
table.insert(stageIds, change.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -778,6 +786,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
-- Apply Rojo removal to Studio (delete the instance)
|
-- Apply Rojo removal to Studio (delete the instance)
|
||||||
Log.info("[Push] Delete: {}", instancePath)
|
Log.info("[Push] Delete: {}", instancePath)
|
||||||
table.insert(pushPatch.removed, idOrInstance)
|
table.insert(pushPatch.removed, idOrInstance)
|
||||||
|
if id then
|
||||||
|
table.insert(stageIds, id)
|
||||||
|
end
|
||||||
elseif selection == "pull" and self.__twoWaySync then
|
elseif selection == "pull" and self.__twoWaySync then
|
||||||
-- Syncback: Create file in Rojo from Studio instance
|
-- Syncback: Create file in Rojo from Studio instance
|
||||||
if instance and instance.Parent then
|
if instance and instance.Parent then
|
||||||
|
|
@ -790,6 +801,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
local guid = HttpService:GenerateGUID(false)
|
local guid = HttpService:GenerateGUID(false)
|
||||||
local tempRef = string.gsub(guid, "-", ""):lower()
|
local tempRef = string.gsub(guid, "-", ""):lower()
|
||||||
pullPatch.added[tempRef] = encoded
|
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.
|
-- Note: We intentionally do NOT pre-insert into InstanceMap here.
|
||||||
-- The VFS watcher will process the written files and assign a
|
-- The VFS watcher will process the written files and assign a
|
||||||
-- server-side Ref ID. Any Ref properties targeting this instance
|
-- server-side Ref ID. Any Ref properties targeting this instance
|
||||||
|
|
@ -818,6 +832,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
-- Apply Rojo addition to Studio
|
-- Apply Rojo addition to Studio
|
||||||
Log.info("[Push] Add: {}.{}", parentPath, instanceName)
|
Log.info("[Push] Add: {}.{}", parentPath, instanceName)
|
||||||
pushPatch.added[id] = change
|
pushPatch.added[id] = change
|
||||||
|
table.insert(stageIds, id)
|
||||||
elseif selection == "pull" and self.__twoWaySync then
|
elseif selection == "pull" and self.__twoWaySync then
|
||||||
-- Don't add in Studio, remove from Rojo
|
-- Don't add in Studio, remove from Rojo
|
||||||
-- Log at info level since this is a major file deletion operation
|
-- Log at info level since this is a major file deletion operation
|
||||||
|
|
@ -846,8 +861,12 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
self:__applyPatch(pushPatch)
|
self:__applyPatch(pushPatch)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Send pull items back to Rojo server
|
-- Send pull items + stage requests to Rojo server
|
||||||
if self.__twoWaySync and not PatchSet.isEmpty(pullPatch) then
|
local hasPullChanges = self.__twoWaySync and not PatchSet.isEmpty(pullPatch)
|
||||||
|
local hasStageIds = #stageIds > 0
|
||||||
|
|
||||||
|
if hasPullChanges or hasStageIds then
|
||||||
|
if hasPullChanges then
|
||||||
local addCount = 0
|
local addCount = 0
|
||||||
for _ in pairs(pullPatch.added) do
|
for _ in pairs(pullPatch.added) do
|
||||||
addCount += 1
|
addCount += 1
|
||||||
|
|
@ -858,7 +877,11 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
|
||||||
#pullPatch.removed,
|
#pullPatch.removed,
|
||||||
#pullPatch.updated
|
#pullPatch.updated
|
||||||
)
|
)
|
||||||
return self.__apiContext:write(pullPatch)
|
end
|
||||||
|
if hasStageIds then
|
||||||
|
Log.info("Requesting git stage for {} files", #stageIds)
|
||||||
|
end
|
||||||
|
return self.__apiContext:write(pullPatch, stageIds)
|
||||||
end
|
end
|
||||||
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use crossbeam_channel::{select, Receiver, RecvError, Sender};
|
||||||
use jod_thread::JoinHandle;
|
use jod_thread::JoinHandle;
|
||||||
use memofs::{IoResultExt, Vfs, VfsEvent};
|
use memofs::{IoResultExt, Vfs, VfsEvent};
|
||||||
use rbx_dom_weak::types::{Ref, Variant};
|
use rbx_dom_weak::types::{Ref, Variant};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -83,6 +84,7 @@ impl ChangeProcessor {
|
||||||
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
critical_error_receiver: Option<Receiver<memofs::WatcherCriticalError>>,
|
critical_error_receiver: Option<Receiver<memofs::WatcherCriticalError>>,
|
||||||
|
git_repo_root: Option<PathBuf>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
|
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
|
||||||
let vfs_receiver = vfs.event_receiver();
|
let vfs_receiver = vfs.event_receiver();
|
||||||
|
|
@ -101,6 +103,7 @@ impl ChangeProcessor {
|
||||||
suppressed_paths,
|
suppressed_paths,
|
||||||
project_root,
|
project_root,
|
||||||
ref_path_index,
|
ref_path_index,
|
||||||
|
git_repo_root,
|
||||||
};
|
};
|
||||||
|
|
||||||
let job_thread = jod_thread::Builder::new()
|
let job_thread = jod_thread::Builder::new()
|
||||||
|
|
@ -245,6 +248,10 @@ struct JobThreadContext {
|
||||||
/// Index of meta/model files that contain `Rojo_Ref_*` attributes.
|
/// Index of meta/model files that contain `Rojo_Ref_*` attributes.
|
||||||
/// Shared with ApiService for efficient rename path updates.
|
/// Shared with ApiService for efficient rename path updates.
|
||||||
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
||||||
|
|
||||||
|
/// Git repository root, if the project is in a git repo.
|
||||||
|
/// Used for auto-staging Source writes.
|
||||||
|
git_repo_root: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobThreadContext {
|
impl JobThreadContext {
|
||||||
|
|
@ -1005,6 +1012,7 @@ impl JobThreadContext {
|
||||||
added_instances: patch_set.added_instances,
|
added_instances: patch_set.added_instances,
|
||||||
removed_instances: patch_set.removed_instances,
|
removed_instances: patch_set.removed_instances,
|
||||||
updated_instances: Vec::new(),
|
updated_instances: Vec::new(),
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let applied = apply_patch_set(&mut tree, structural_patch);
|
let applied = apply_patch_set(&mut tree, structural_patch);
|
||||||
|
|
@ -1812,6 +1820,10 @@ impl JobThreadContext {
|
||||||
id,
|
id,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
|
} else if patch_set.stage_ids.contains(&id) {
|
||||||
|
if let Some(ref repo_root) = self.git_repo_root {
|
||||||
|
crate::git::git_add(repo_root, std::slice::from_ref(write_path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Cannot change Source to non-string value.");
|
log::warn!("Cannot change Source to non-string value.");
|
||||||
|
|
|
||||||
229
src/git.rs
Normal file
229
src/git.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use rbx_dom_weak::types::Ref;
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
|
||||||
|
use crate::{snapshot::RojoTree, web::interface::GitMetadata};
|
||||||
|
|
||||||
|
const SCRIPT_CLASSES: &[&str] = &["Script", "LocalScript", "ModuleScript"];
|
||||||
|
|
||||||
|
fn is_script_class(class: &str) -> bool {
|
||||||
|
SCRIPT_CLASSES.contains(&class)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_repo_root(project_root: &Path) -> Option<PathBuf> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-C",
|
||||||
|
&project_root.to_string_lossy(),
|
||||||
|
"rev-parse",
|
||||||
|
"--show-toplevel",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let path_str = stdout.trim();
|
||||||
|
if path_str.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(PathBuf::from(path_str))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_changed_files(repo_root: &Path) -> Option<HashSet<PathBuf>> {
|
||||||
|
let mut changed = HashSet::new();
|
||||||
|
|
||||||
|
let diff_output = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-C",
|
||||||
|
&repo_root.to_string_lossy(),
|
||||||
|
"diff",
|
||||||
|
"HEAD",
|
||||||
|
"--name-only",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if diff_output.status.success() {
|
||||||
|
for line in String::from_utf8_lossy(&diff_output.stdout).lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
changed.insert(PathBuf::from(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("git diff HEAD failed (possibly no commits yet), skipping diff");
|
||||||
|
}
|
||||||
|
|
||||||
|
let untracked_output = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-C",
|
||||||
|
&repo_root.to_string_lossy(),
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if untracked_output.status.success() {
|
||||||
|
for line in String::from_utf8_lossy(&untracked_output.stdout).lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
changed.insert(PathBuf::from(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed.is_empty() && !diff_output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_show(repo_root: &Path, object_ref: &str) -> Option<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["-C", &repo_root.to_string_lossy(), "show", object_ref])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(output.stdout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_show_head(repo_root: &Path, rel_path: &Path) -> Option<String> {
|
||||||
|
let object_ref = format!("HEAD:{}", rel_path.to_string_lossy().replace('\\', "/"));
|
||||||
|
git_show(repo_root, &object_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_show_staged(repo_root: &Path, rel_path: &Path) -> Option<String> {
|
||||||
|
let object_ref = format!(":0:{}", rel_path.to_string_lossy().replace('\\', "/"));
|
||||||
|
git_show(repo_root, &object_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_blob_sha1(content: &str) -> String {
|
||||||
|
let header = format!("blob {}\0", content.len());
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(header.as_bytes());
|
||||||
|
hasher.update(content.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_add(repo_root: &Path, paths: &[PathBuf]) {
|
||||||
|
if paths.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(["-C", &repo_root.to_string_lossy(), "add", "--"]);
|
||||||
|
for path in paths {
|
||||||
|
cmd.arg(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd.output() {
|
||||||
|
Ok(output) => {
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
log::warn!("git add failed: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to run git add: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResolvedInstance {
|
||||||
|
id: Ref,
|
||||||
|
class_name: String,
|
||||||
|
rel_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes git metadata for the two-way sync confirmation UI.
|
||||||
|
///
|
||||||
|
/// Uses a two-phase approach to avoid holding the tree lock during I/O:
|
||||||
|
/// 1. Run git commands to get changed files (no lock)
|
||||||
|
/// 2. Briefly lock tree to resolve file paths to instance Refs and class names
|
||||||
|
/// 3. Run git show for each changed script and compute hashes (no lock)
|
||||||
|
pub fn compute_git_metadata(
|
||||||
|
tree_handle: &Arc<Mutex<RojoTree>>,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Option<GitMetadata> {
|
||||||
|
let repo_root = git_repo_root(project_root)?;
|
||||||
|
let changed_files = git_changed_files(&repo_root)?;
|
||||||
|
|
||||||
|
if changed_files.is_empty() {
|
||||||
|
return Some(GitMetadata {
|
||||||
|
changed_ids: Vec::new(),
|
||||||
|
script_committed_hashes: HashMap::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief lock: resolve changed file paths to instance Refs and class names
|
||||||
|
let resolved: Vec<ResolvedInstance> = {
|
||||||
|
let tree = tree_handle.lock().unwrap();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for rel_path in &changed_files {
|
||||||
|
let abs_path = repo_root.join(rel_path);
|
||||||
|
let ids = tree.get_ids_at_path(&abs_path);
|
||||||
|
|
||||||
|
for &id in ids {
|
||||||
|
if let Some(instance) = tree.get_instance(id) {
|
||||||
|
result.push(ResolvedInstance {
|
||||||
|
id,
|
||||||
|
class_name: instance.class_name().to_string(),
|
||||||
|
rel_path: rel_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}; // Lock released
|
||||||
|
|
||||||
|
let changed_ids: Vec<Ref> = resolved.iter().map(|ri| ri.id).collect();
|
||||||
|
let mut script_committed_hashes: HashMap<Ref, Vec<String>> = HashMap::new();
|
||||||
|
|
||||||
|
for ri in &resolved {
|
||||||
|
if !is_script_class(&ri.class_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hashes = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
if let Some(head_content) = git_show_head(&repo_root, &ri.rel_path) {
|
||||||
|
hashes.push(compute_blob_sha1(&head_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(staged_content) = git_show_staged(&repo_root, &ri.rel_path) {
|
||||||
|
let staged_hash = compute_blob_sha1(&staged_content);
|
||||||
|
if hashes.is_empty() || hashes[0] != staged_hash {
|
||||||
|
hashes.push(staged_hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hashes.is_empty() {
|
||||||
|
script_committed_hashes.insert(ri.id, hashes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(GitMetadata {
|
||||||
|
changed_ids,
|
||||||
|
script_committed_hashes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod cli;
|
||||||
mod tree_view;
|
mod tree_view;
|
||||||
|
|
||||||
mod change_processor;
|
mod change_processor;
|
||||||
|
pub mod git;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod json;
|
mod json;
|
||||||
mod lua_ast;
|
mod lua_ast;
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,10 @@ pub struct ServeSession {
|
||||||
/// Shared between ApiService (writes) and ChangeProcessor (rename updates).
|
/// Shared between ApiService (writes) and ChangeProcessor (rename updates).
|
||||||
/// `None` for oneshot sessions.
|
/// `None` for oneshot sessions.
|
||||||
ref_path_index: Option<Arc<Mutex<crate::RefPathIndex>>>,
|
ref_path_index: Option<Arc<Mutex<crate::RefPathIndex>>>,
|
||||||
|
|
||||||
|
/// Root of the git repository, if the project is inside one.
|
||||||
|
/// Computed once at session start for use by auto-staging.
|
||||||
|
git_repo_root: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServeSession {
|
impl ServeSession {
|
||||||
|
|
@ -172,6 +176,8 @@ impl ServeSession {
|
||||||
let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||||
let ref_path_index = Arc::new(Mutex::new(crate::RefPathIndex::new()));
|
let ref_path_index = Arc::new(Mutex::new(crate::RefPathIndex::new()));
|
||||||
|
|
||||||
|
let git_repo_root = crate::git::git_repo_root(root_project.folder_location());
|
||||||
|
|
||||||
log::trace!("Starting ChangeProcessor");
|
log::trace!("Starting ChangeProcessor");
|
||||||
let change_processor = ChangeProcessor::start(
|
let change_processor = ChangeProcessor::start(
|
||||||
Arc::clone(&tree),
|
Arc::clone(&tree),
|
||||||
|
|
@ -182,6 +188,7 @@ impl ServeSession {
|
||||||
Arc::clone(&ref_path_index),
|
Arc::clone(&ref_path_index),
|
||||||
root_project.folder_location().to_path_buf(),
|
root_project.folder_location().to_path_buf(),
|
||||||
critical_error_receiver,
|
critical_error_receiver,
|
||||||
|
git_repo_root.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -195,6 +202,7 @@ impl ServeSession {
|
||||||
vfs,
|
vfs,
|
||||||
suppressed_paths: Some(suppressed_paths),
|
suppressed_paths: Some(suppressed_paths),
|
||||||
ref_path_index: Some(ref_path_index),
|
ref_path_index: Some(ref_path_index),
|
||||||
|
git_repo_root,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,6 +228,7 @@ impl ServeSession {
|
||||||
vfs: Arc::new(vfs),
|
vfs: Arc::new(vfs),
|
||||||
suppressed_paths: None,
|
suppressed_paths: None,
|
||||||
ref_path_index: None,
|
ref_path_index: None,
|
||||||
|
git_repo_root: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,6 +321,10 @@ impl ServeSession {
|
||||||
self.root_project.folder_location()
|
self.root_project.folder_location()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_repo_root(&self) -> Option<&Path> {
|
||||||
|
self.git_repo_root.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root_project(&self) -> &Project {
|
pub fn root_project(&self) -> &Project {
|
||||||
&self.root_project
|
&self.root_project
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! Defines the data structures used for describing instance patches.
|
//! Defines the data structures used for describing instance patches.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use rbx_dom_weak::{
|
use rbx_dom_weak::{
|
||||||
types::{Ref, Variant},
|
types::{Ref, Variant},
|
||||||
HashMapExt as _, Ustr, UstrMap,
|
HashMapExt as _, Ustr, UstrMap,
|
||||||
|
|
@ -18,6 +20,10 @@ pub struct PatchSet {
|
||||||
pub removed_instances: Vec<Ref>,
|
pub removed_instances: Vec<Ref>,
|
||||||
pub added_instances: Vec<PatchAdd>,
|
pub added_instances: Vec<PatchAdd>,
|
||||||
pub updated_instances: Vec<PatchUpdate>,
|
pub updated_instances: Vec<PatchUpdate>,
|
||||||
|
/// Instance IDs whose backing files should be staged via git add after writing.
|
||||||
|
/// Populated from WriteRequest.stage_ids, consumed by change_processor.
|
||||||
|
#[serde(default, skip)]
|
||||||
|
pub stage_ids: HashSet<Ref>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PatchSet {
|
impl PatchSet {
|
||||||
|
|
@ -26,6 +32,7 @@ impl PatchSet {
|
||||||
removed_instances: Vec::new(),
|
removed_instances: Vec::new(),
|
||||||
added_instances: Vec::new(),
|
added_instances: Vec::new(),
|
||||||
updated_instances: Vec::new(),
|
updated_instances: Vec::new(),
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,7 @@ mod test {
|
||||||
parent_id: root_id,
|
parent_id: root_id,
|
||||||
instance: snapshot.clone(),
|
instance: snapshot.clone(),
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -438,6 +439,7 @@ mod test {
|
||||||
|
|
||||||
let patch_set = PatchSet {
|
let patch_set = PatchSet {
|
||||||
updated_instances: vec![patch],
|
updated_instances: vec![patch],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ mod test {
|
||||||
}],
|
}],
|
||||||
added_instances: Vec::new(),
|
added_instances: Vec::new(),
|
||||||
removed_instances: Vec::new(),
|
removed_instances: Vec::new(),
|
||||||
|
stage_ids: std::collections::HashSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(patch_set, expected_patch_set);
|
assert_eq!(patch_set, expected_patch_set);
|
||||||
|
|
@ -397,6 +398,7 @@ mod test {
|
||||||
}],
|
}],
|
||||||
updated_instances: Vec::new(),
|
updated_instances: Vec::new(),
|
||||||
removed_instances: Vec::new(),
|
removed_instances: Vec::new(),
|
||||||
|
stage_ids: std::collections::HashSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(patch_set, expected_patch_set);
|
assert_eq!(patch_set, expected_patch_set);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use insta::assert_yaml_snapshot;
|
use insta::assert_yaml_snapshot;
|
||||||
|
|
||||||
use rbx_dom_weak::{ustr, UstrMap};
|
use rbx_dom_weak::{ustr, UstrMap};
|
||||||
|
|
@ -23,6 +25,7 @@ fn set_name_and_class_name() {
|
||||||
changed_properties: Default::default(),
|
changed_properties: Default::default(),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,6 +53,7 @@ fn add_property() {
|
||||||
changed_properties: UstrMap::from_iter([(ustr("Foo"), Some("Value of Foo".into()))]),
|
changed_properties: UstrMap::from_iter([(ustr("Foo"), Some("Value of Foo".into()))]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -89,6 +93,7 @@ fn remove_property() {
|
||||||
changed_properties: UstrMap::from_iter([(ustr("Foo"), None)]),
|
changed_properties: UstrMap::from_iter([(ustr("Foo"), None)]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -144,6 +149,7 @@ fn apply_ref_property_update() {
|
||||||
)]),
|
)]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -203,6 +209,7 @@ fn apply_nil_ref_skips_in_forward_sync() {
|
||||||
)]),
|
)]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -258,6 +265,7 @@ fn apply_ref_property_removal() {
|
||||||
changed_properties: UstrMap::from_iter([(ustr("PrimaryPart"), None)]),
|
changed_properties: UstrMap::from_iter([(ustr("PrimaryPart"), None)]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -314,6 +322,7 @@ fn apply_multiple_ref_property_updates() {
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -365,6 +374,7 @@ fn apply_ref_to_nonexistent_instance() {
|
||||||
)]),
|
)]),
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: HashSet::new(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,7 @@ impl ApiService {
|
||||||
|
|
||||||
/// Get a summary of information about the server
|
/// Get a summary of information about the server
|
||||||
async fn handle_api_rojo(&self) -> Response<Full<Bytes>> {
|
async fn handle_api_rojo(&self) -> Response<Full<Bytes>> {
|
||||||
let tree = self.serve_session.tree();
|
let root_instance_id = self.serve_session.tree().get_root_id();
|
||||||
let root_instance_id = tree.get_root_id();
|
|
||||||
|
|
||||||
let ignore_hidden_services = self.serve_session.ignore_hidden_services();
|
let ignore_hidden_services = self.serve_session.ignore_hidden_services();
|
||||||
let visible_services = if ignore_hidden_services {
|
let visible_services = if ignore_hidden_services {
|
||||||
|
|
@ -274,6 +273,11 @@ impl ApiService {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let git_metadata = crate::git::compute_git_metadata(
|
||||||
|
&self.serve_session.tree_handle(),
|
||||||
|
self.serve_session.root_dir(),
|
||||||
|
);
|
||||||
|
|
||||||
msgpack_ok(&ServerInfoResponse {
|
msgpack_ok(&ServerInfoResponse {
|
||||||
server_version: SERVER_VERSION.to_owned(),
|
server_version: SERVER_VERSION.to_owned(),
|
||||||
protocol_version: PROTOCOL_VERSION,
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
|
@ -288,6 +292,7 @@ impl ApiService {
|
||||||
sync_source_only: true,
|
sync_source_only: true,
|
||||||
ignore_hidden_services,
|
ignore_hidden_services,
|
||||||
visible_services,
|
visible_services,
|
||||||
|
git_metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -735,14 +740,38 @@ impl ApiService {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let stage_ids: HashSet<Ref> = request.stage_ids.into_iter().collect();
|
||||||
|
|
||||||
tree_mutation_sender
|
tree_mutation_sender
|
||||||
.send(PatchSet {
|
.send(PatchSet {
|
||||||
removed_instances: actually_removed,
|
removed_instances: actually_removed,
|
||||||
added_instances,
|
added_instances,
|
||||||
updated_instances,
|
updated_instances,
|
||||||
|
stage_ids: stage_ids.clone(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
if let Some(repo_root) = self.serve_session.git_repo_root() {
|
||||||
|
if !stage_ids.is_empty() {
|
||||||
|
let tree = self.serve_session.tree();
|
||||||
|
let paths_to_stage: Vec<PathBuf> = stage_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&id| {
|
||||||
|
tree.get_instance(id).and_then(|inst| {
|
||||||
|
inst.metadata().instigating_source.as_ref().map(|src| {
|
||||||
|
src.path().to_path_buf()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
drop(tree);
|
||||||
|
|
||||||
|
if !paths_to_stage.is_empty() {
|
||||||
|
crate::git::git_add(repo_root, &paths_to_stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
msgpack_ok(WriteResponse { session_id })
|
msgpack_ok(WriteResponse { session_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,16 @@ fn property_filter(value: Option<&Variant>) -> bool {
|
||||||
ty != Some(VariantType::SharedString)
|
ty != Some(VariantType::SharedString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Git-related metadata sent to the plugin for smart sync direction defaults.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitMetadata {
|
||||||
|
pub changed_ids: Vec<Ref>,
|
||||||
|
/// SHA1 hashes of prior versions (git blob format) for changed script instances.
|
||||||
|
/// Contains 1-2 hashes per script: HEAD version and staged version (if different).
|
||||||
|
pub script_committed_hashes: HashMap<Ref, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Response body from /api/rojo
|
/// Response body from /api/rojo
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -202,6 +212,8 @@ pub struct ServerInfoResponse {
|
||||||
/// Services not in this list should be ignored during sync operations.
|
/// Services not in this list should be ignored during sync operations.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub visible_services: Vec<String>,
|
pub visible_services: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub git_metadata: Option<GitMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua
|
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua
|
||||||
|
|
@ -287,6 +299,9 @@ pub struct WriteRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub added: HashMap<Ref, AddedInstance>,
|
pub added: HashMap<Ref, AddedInstance>,
|
||||||
pub updated: Vec<InstanceUpdate>,
|
pub updated: Vec<InstanceUpdate>,
|
||||||
|
/// Instance IDs whose backing files should be staged via git add.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_ids: Vec<Ref>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -849,6 +849,7 @@ fn api_write_then_filesystem_change() {
|
||||||
changed_properties: props,
|
changed_properties: props,
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
|
||||||
|
|
@ -780,6 +780,7 @@ fn api_write_existing_instance() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
session
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ fn send_write_request(
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
session
|
||||||
|
|
@ -904,6 +905,7 @@ fn rapid_syncs_dont_create_duplicates() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
session
|
||||||
|
|
@ -1179,6 +1181,7 @@ fn scripts_only_mode_rapid_syncs() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
session
|
||||||
|
|
@ -1802,6 +1805,7 @@ fn sync_immediate_double_sync() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -1826,6 +1830,7 @@ fn sync_immediate_double_sync() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map2,
|
added: added_map2,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request2).unwrap();
|
session.post_api_write(&write_request2).unwrap();
|
||||||
|
|
||||||
|
|
@ -1871,6 +1876,7 @@ fn stress_test_50_rapid_syncs() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
// No delay between syncs!
|
// No delay between syncs!
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ fn send_update(
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: vec![update],
|
updated: vec![update],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session
|
session
|
||||||
.post_api_write(&write_request)
|
.post_api_write(&write_request)
|
||||||
|
|
@ -115,6 +116,7 @@ fn send_removal(
|
||||||
removed: ids,
|
removed: ids,
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session
|
session
|
||||||
.post_api_write(&write_request)
|
.post_api_write(&write_request)
|
||||||
|
|
@ -704,6 +706,7 @@ fn echo_suppression_prevents_redundant_patches() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -1127,6 +1130,7 @@ fn add_child_converts_standalone_to_directory() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -1279,6 +1283,7 @@ fn add_child_to_encoded_module_creates_encoded_directory() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -1352,6 +1357,7 @@ fn add_child_to_encoded_script_strips_suffix_correctly() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -1418,6 +1424,7 @@ fn add_child_to_encoded_model_creates_encoded_directory() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -1521,6 +1528,7 @@ fn send_update_fast(
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: vec![update],
|
updated: vec![update],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session
|
session
|
||||||
.post_api_write(&write_request)
|
.post_api_write(&write_request)
|
||||||
|
|
@ -1539,6 +1547,7 @@ fn send_update_no_wait(
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: vec![update],
|
updated: vec![update],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session
|
session
|
||||||
.post_api_write(&write_request)
|
.post_api_write(&write_request)
|
||||||
|
|
@ -1556,6 +1565,7 @@ fn send_removal_fast(
|
||||||
removed: ids,
|
removed: ids,
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session
|
session
|
||||||
.post_api_write(&write_request)
|
.post_api_write(&write_request)
|
||||||
|
|
@ -2198,6 +2208,7 @@ fn multi_instance_source_update_single_request() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: updates,
|
updated: updates,
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
wait_for_settle();
|
wait_for_settle();
|
||||||
|
|
@ -2234,6 +2245,7 @@ fn multi_instance_rename_single_request() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: updates,
|
updated: updates,
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
wait_for_settle();
|
wait_for_settle();
|
||||||
|
|
@ -2337,6 +2349,7 @@ fn echo_suppression_rapid_adds_10x() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -2409,6 +2422,7 @@ fn echo_suppression_mixed_operations() {
|
||||||
removed,
|
removed,
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: updates,
|
updated: updates,
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
wait_for_settle();
|
wait_for_settle();
|
||||||
|
|
@ -2758,6 +2772,7 @@ fn fuzz_multi_instance_15_iterations() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: updates,
|
updated: updates,
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
|
@ -4159,6 +4174,7 @@ fn multi_instance_combined_rename_class_source_batch() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
updated: updates,
|
updated: updates,
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
wait_for_settle();
|
wait_for_settle();
|
||||||
|
|
@ -5126,6 +5142,7 @@ fn add_instance_with_forbidden_chars_creates_slug_and_meta() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -5191,6 +5208,7 @@ fn add_two_colliding_instances_deduplicates() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -5470,6 +5488,7 @@ fn add_three_colliding_instances_deduplicates() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -5636,6 +5655,7 @@ fn add_case_insensitive_colliding_instances() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -6753,6 +6773,7 @@ fn ref_to_instance_added_in_same_request() {
|
||||||
changed_properties: props,
|
changed_properties: props,
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
session
|
||||||
|
|
@ -7266,6 +7287,7 @@ fn ref_same_batch_add_target_includes_extension() {
|
||||||
changed_properties: update_props,
|
changed_properties: update_props,
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -7320,6 +7342,7 @@ fn ref_same_batch_add_module_target_includes_extension() {
|
||||||
changed_properties: update_props,
|
changed_properties: update_props,
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -7367,6 +7390,7 @@ fn ref_same_batch_add_folder_target_no_extension() {
|
||||||
changed_properties: update_props,
|
changed_properties: update_props,
|
||||||
changed_metadata: None,
|
changed_metadata: None,
|
||||||
}],
|
}],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -7430,6 +7454,7 @@ fn delete_deduped_instance_group_to_1_cleanup() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -7515,6 +7540,7 @@ fn delete_base_deduped_instance_promotes_lowest() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -7593,6 +7619,7 @@ fn delete_middle_deduped_instance_tolerates_gap() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -7704,6 +7731,7 @@ fn add_same_named_folders_deduplicates() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -7844,6 +7872,7 @@ fn ref_through_deduped_instance() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
@ -7963,6 +7992,7 @@ fn ref_path_updated_after_dedup_cleanup() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
@ -8095,6 +8125,7 @@ fn rename_ref_target_updates_ref_paths() {
|
||||||
removed: vec![],
|
removed: vec![],
|
||||||
added: added_map,
|
added: added_map,
|
||||||
updated: vec![],
|
updated: vec![],
|
||||||
|
stage_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
session.post_api_write(&write_request).unwrap();
|
session.post_api_write(&write_request).unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue