From 7ba04f7f6afcc169f3923fd5cca47b344fd5beae Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 17 Feb 2026 20:36:28 -0600 Subject: [PATCH] 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. --- .../git-based_sync_defaults_be6371b6.plan.md | 329 +++++++++++++++++ .cursor/rules/atlas-api.mdc | 41 ++- .cursor/rules/atlas-plugin.mdc | 338 ++++++++++++++++++ .../rules/{rbx-dom.mdc => atlas-rbx-dom.mdc} | 2 +- .cursor/rules/atlas-src.mdc | 3 +- .cursor/rules/atlas-syncback.mdc | 2 +- .cursor/rules/atlas.mdc | 3 + .cursor/rules/plugin.mdc | 136 ------- Cargo.lock | 21 +- Cargo.toml | 1 + plugin/src/ApiContext.lua | 3 +- plugin/src/App/init.lua | 27 +- plugin/src/PatchTree.lua | 38 +- plugin/src/SHA1.luau | 107 ++++++ plugin/src/ServeSession.lua | 47 ++- src/change_processor.rs | 12 + src/git.rs | 229 ++++++++++++ src/lib.rs | 1 + src/serve_session.rs | 13 + src/snapshot/patch.rs | 7 + src/snapshot/patch_apply.rs | 2 + src/snapshot/patch_compute.rs | 2 + src/snapshot/tests/apply.rs | 10 + src/web/api.rs | 33 +- src/web/interface.rs | 15 + tests/tests/connected_mode.rs | 1 + tests/tests/serve.rs | 1 + tests/tests/syncback_format_transitions.rs | 6 + tests/tests/two_way_sync.rs | 31 ++ 29 files changed, 1285 insertions(+), 176 deletions(-) create mode 100644 .cursor/plans/git-based_sync_defaults_be6371b6.plan.md create mode 100644 .cursor/rules/atlas-plugin.mdc rename .cursor/rules/{rbx-dom.mdc => atlas-rbx-dom.mdc} (98%) delete mode 100644 .cursor/rules/plugin.mdc create mode 100644 plugin/src/SHA1.luau create mode 100644 src/git.rs diff --git a/.cursor/plans/git-based_sync_defaults_be6371b6.plan.md b/.cursor/plans/git-based_sync_defaults_be6371b6.plan.md new file mode 100644 index 00000000..95d6f521 --- /dev/null +++ b/.cursor/plans/git-based_sync_defaults_be6371b6.plan.md @@ -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 \0"`) 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**`: Runs `git -C rev-parse --show-toplevel`. Returns `None` if not a git repo or git not installed. +- `**git_changed_files(repo_root: &Path) -> Option>**`: Runs `git -C diff HEAD --name-only` to get files differing from HEAD (covers staged + unstaged). Also runs `git -C 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`**: Runs `git -C show HEAD:`. Returns committed content at HEAD. Returns `None` for new files. +- `**git_show_staged(repo_root: &Path, rel_path: &Path) -> Option`**: Runs `git -C show :0:`. 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 \0")`. Returns 40-char lowercase hex string. No normalization. +- `**git_add(repo_root: &Path, paths: &[PathBuf])`**: Runs `git -C add -- `. Stages files for commit. No-op if paths is empty. +- `**compute_git_metadata(path_snapshot: &PathSnapshot, project_root: &Path) -> Option`**: 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, + /// 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>, +} +``` + +Add to `ServerInfoResponse`: + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub git_metadata: Option, +``` + +**WriteRequest** (add `stage_ids` field): + +```rust +pub struct WriteRequest { + pub session_id: SessionId, + pub removed: Vec, + #[serde(default)] + pub added: HashMap, + pub updated: Vec, + /// 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, +} +``` + +**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, +``` + +### 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` 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` 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 \0"` 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:` and `git show :0:` (staged), compute SHA1 using `"blob \0"`. 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). + diff --git a/.cursor/rules/atlas-api.mdc b/.cursor/rules/atlas-api.mdc index f226a83d..1ed8b100 100644 --- a/.cursor/rules/atlas-api.mdc +++ b/.cursor/rules/atlas-api.mdc @@ -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 --- @@ -63,10 +63,22 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem "visibleServices": ["Workspace", "ServerScriptService"], "placeId": 123456, "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 \0"`). 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`) ```json @@ -74,10 +86,13 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem "sessionId": "uuid", "removed": ["id-1"], "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) ## WebSocket Protocol (v6) @@ -603,6 +618,25 @@ end - Safe for production environments - 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 \0")` (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) | 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/change_processor.rs` - FS writer | `plugin/src/Reconciler/` - Patch application | | `src/rojo_ref.rs` - Ref paths | `plugin/src/Reconciler/matching.lua` - Matching | +| `src/git.rs` - Git integration | `plugin/src/PatchTree.lua` - Default selections | diff --git a/.cursor/rules/atlas-plugin.mdc b/.cursor/rules/atlas-plugin.mdc new file mode 100644 index 00000000..0dfb3a67 --- /dev/null +++ b/.cursor/rules/atlas-plugin.mdc @@ -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. diff --git a/.cursor/rules/rbx-dom.mdc b/.cursor/rules/atlas-rbx-dom.mdc similarity index 98% rename from .cursor/rules/rbx-dom.mdc rename to .cursor/rules/atlas-rbx-dom.mdc index 0bd1a857..5ec16ca5 100644 --- a/.cursor/rules/rbx-dom.mdc +++ b/.cursor/rules/atlas-rbx-dom.mdc @@ -1,5 +1,5 @@ --- -globs: rbx-dom/**/*.rs,rbx-dom/**/*.lua,rbx-dom/**/*.toml +globs: rbx-dom/** alwaysApply: false --- diff --git a/.cursor/rules/atlas-src.mdc b/.cursor/rules/atlas-src.mdc index 611dac43..d354ca46 100644 --- a/.cursor/rules/atlas-src.mdc +++ b/.cursor/rules/atlas-src.mdc @@ -1,5 +1,5 @@ --- -globs: src/**/*.rs,crates/**/*.rs +globs: src/**,crates/** alwaysApply: false --- @@ -380,6 +380,7 @@ src/ meta.rs - Meta file helpers web/ - HTTP/WebSocket 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) variant_eq.rs - Property value comparison lib.rs - Public API diff --git a/.cursor/rules/atlas-syncback.mdc b/.cursor/rules/atlas-syncback.mdc index 236a3210..08aa6f0b 100644 --- a/.cursor/rules/atlas-syncback.mdc +++ b/.cursor/rules/atlas-syncback.mdc @@ -1,5 +1,5 @@ --- -globs: src/syncback/**/*.rs,src/cli/syncback.rs +globs: src/syncback/**,src/cli/syncback.rs alwaysApply: false --- diff --git a/.cursor/rules/atlas.mdc b/.cursor/rules/atlas.mdc index 4a94a3ac..0b1edfa7 100644 --- a/.cursor/rules/atlas.mdc +++ b/.cursor/rules/atlas.mdc @@ -323,6 +323,7 @@ src/ - Rust server/CLI meta.rs - Meta file read/write helpers web/ - HTTP/WebSocket server 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) variant_eq.rs - Property value comparison @@ -333,6 +334,8 @@ plugin/src/ - Lua Studio plugin trueEquals.lua - Shared value equality module ChangeBatcher/ - Two-way sync patch creation 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 rbx-dom/ - Git submodule (separate repo) diff --git a/.cursor/rules/plugin.mdc b/.cursor/rules/plugin.mdc deleted file mode 100644 index 7a69eb54..00000000 --- a/.cursor/rules/plugin.mdc +++ /dev/null @@ -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) - added = { ["id3"] = {...} }, -- Map: ID → Instance data - updated = { -- Array of updates (Vec) - { 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. diff --git a/Cargo.lock b/Cargo.lock index 2ae6ddb4..1fa4b66c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ dependencies = [ "bincode", "blake3", "bytes", - "clap 4.5.58", + "clap 4.5.59", "criterion", "crossbeam-channel", "csv", @@ -227,6 +227,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_yaml", + "sha1 0.10.6", "strum", "tempfile", "termcolor", @@ -536,9 +537,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -546,9 +547,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -710,7 +711,7 @@ dependencies = [ "anes", "cast", "ciborium", - "clap 4.5.58", + "clap 4.5.59", "criterion-plot", "itertools", "num-traits", @@ -3555,9 +3556,9 @@ dependencies = [ [[package]] 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" -checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -3737,9 +3738,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index ecf5f832..43245e01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ data-encoding = "2.10.0" pathdiff = "0.2.3" blake3 = "1.8.3" +sha1 = "0.10" float-cmp = "0.10.0" indexmap = { version = "2.13.0", features = ["serde"] } rmp-serde = "1.3.1" diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index 7ed53fbc..8fb3e0af 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -188,7 +188,7 @@ function ApiContext:read(ids) end) end -function ApiContext:write(patch) +function ApiContext:write(patch, stageIds) local url = ("%s/api/write"):format(self.__baseUrl) -- Log what we're sending to the server (syncback/pull) @@ -248,6 +248,7 @@ function ApiContext:write(patch) removed = patch.removed, updated = updated, added = added, + stageIds = stageIds, } body = Http.msgpackEncode(body) diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index f5086e2a..03f89b8b 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -653,10 +653,12 @@ function App:startSession() }) end) + local cachedServerInfo = nil + self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap) - -- Build new tree for patch + local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata self:setState({ - patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }), + patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }, gitMetadata), }) end) self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch) @@ -790,6 +792,8 @@ function App:startSession() local initialSyncConfirmed = false serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) + cachedServerInfo = serverInfo + -- Filter out the DataModel name change from the patch -- The project name (DataModel.Name) is managed by Studio independently PatchSet.removeDataModelName(patch, instanceMap) @@ -864,7 +868,7 @@ function App:startSession() }) self:setState({ appStatus = AppStatus.Confirming, - patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }), + patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, serverInfo.gitMetadata), confirmData = { serverInfo = serverInfo, }, @@ -914,8 +918,9 @@ function App:startSession() -- Update the patchTree when new changes arrive during confirmation -- The changedIds parameter contains IDs of items that were modified -- so their selections can be reset (user must re-review) + local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata self:setState({ - patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }), + patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata), changedIds = changedIds, -- Pass to UI so it can unselect changed items }) end) @@ -1045,7 +1050,19 @@ function App:render() self.confirmationBindable:Fire("Abort") end, onConfirm = function(selections) - self.confirmationBindable:Fire({ type = "Confirm", selections = selections }) + local autoSelectedIds = {} + if self.state.patchTree then + self.state.patchTree:forEach(function(node) + if node.patchType and node.defaultSelection ~= nil then + autoSelectedIds[node.id] = true + end + end) + end + self.confirmationBindable:Fire({ + type = "Confirm", + selections = selections, + autoSelectedIds = autoSelectedIds, + }) end, }), diff --git a/plugin/src/PatchTree.lua b/plugin/src/PatchTree.lua index 700c3812..dd74006f 100644 --- a/plugin/src/PatchTree.lua +++ b/plugin/src/PatchTree.lua @@ -13,6 +13,7 @@ local Log = require(Packages.Log) local ChangeMetadata = require(Plugin.ChangeMetadata) local Config = require(Plugin.Config) +local SHA1 = require(Plugin.SHA1) local Timer = require(Plugin.Timer) local Types = require(Plugin.Types) local decodeValue = require(Plugin.Reconciler.decodeValue) @@ -212,7 +213,8 @@ local PatchTree = {} -- Builds a new tree from a patch and instanceMap -- uses changeListHeaders in node.changeList -function PatchTree.build(patch, instanceMap, changeListHeaders) +-- gitMetadata is optional: { changedIds: {string}, scriptCommittedHashes: {[string]: {string}} } +function PatchTree.build(patch, instanceMap, changeListHeaders, gitMetadata) Timer.start("PatchTree.build") local clock = os.clock() @@ -220,6 +222,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) local knownAncestors = {} + local changedIdSet = nil + if gitMetadata and gitMetadata.changedIds then + changedIdSet = {} + for _, id in gitMetadata.changedIds do + changedIdSet[id] = true + end + end + Timer.start("patch.updated") for _, change in patch.updated do clock = yieldIfNeeded(clock) @@ -304,8 +314,28 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) table.insert(changeList, 1, changeListHeaders) end - -- Add this node to tree - -- Default to nil (unselected) - user must explicitly choose Push/Pull/Skip + -- Compute default selection from git metadata + local defaultSelection = nil + if changedIdSet then + if not changedIdSet[change.id] then + defaultSelection = "pull" + elseif instance:IsA("LuaSourceContainer") + and gitMetadata.scriptCommittedHashes then + local hashes = gitMetadata.scriptCommittedHashes[change.id] + if hashes then + local source = instance.Source + local gitBlob = "blob " .. tostring(#source) .. "\0" .. source + local studioHash = SHA1(buffer.fromstring(gitBlob)) + for _, hash in hashes do + if studioHash == hash then + defaultSelection = "push" + break + end + end + end + end + end + tree:addNode(instanceMap.fromInstances[instance.Parent], { id = change.id, patchType = "Edit", @@ -314,7 +344,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) instance = instance, changeInfo = changeInfo, changeList = changeList, - defaultSelection = nil, + defaultSelection = defaultSelection, }) end Timer.stop() diff --git a/plugin/src/SHA1.luau b/plugin/src/SHA1.luau new file mode 100644 index 00000000..a300dd81 --- /dev/null +++ b/plugin/src/SHA1.luau @@ -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 diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 039b4875..8dd1098f 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -695,6 +695,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) elseif type(userDecision) == "table" and userDecision.type == "Confirm" then -- New: Apply based on per-item selections local selections = userDecision.selections or {} + local autoSelectedIds = userDecision.autoSelectedIds or {} -- Log selection summary (debug level) local pushCount, pullCount, ignoreCount = 0, 0, 0 @@ -713,6 +714,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) local pushPatch = PatchSet.newEmpty() -- Items to apply to Studio local pullPatch = PatchSet.newEmpty() -- Items to send back to Rojo + -- Build stage_ids: all push-accepted IDs + auto-selected pull-accepted IDs + local stageIds = {} + -- Process updated items for _, change in catchUpPatch.updated do local selection = selections[change.id] @@ -723,6 +727,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) -- Apply Rojo changes to Studio Log.info("[Push] Update: {}", instancePath) table.insert(pushPatch.updated, change) + table.insert(stageIds, change.id) elseif selection == "pull" and self.__twoWaySync then -- Send Studio state back to Rojo if instance then @@ -749,6 +754,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) ) if update then table.insert(pullPatch.updated, update) + if autoSelectedIds[change.id] then + table.insert(stageIds, change.id) + end end end end @@ -778,6 +786,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) -- Apply Rojo removal to Studio (delete the instance) Log.info("[Push] Delete: {}", instancePath) table.insert(pushPatch.removed, idOrInstance) + if id then + table.insert(stageIds, id) + end elseif selection == "pull" and self.__twoWaySync then -- Syncback: Create file in Rojo from Studio instance if instance and instance.Parent then @@ -790,6 +801,9 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) local guid = HttpService:GenerateGUID(false) local tempRef = string.gsub(guid, "-", ""):lower() pullPatch.added[tempRef] = encoded + if id and autoSelectedIds[id] then + table.insert(stageIds, tempRef) + end -- Note: We intentionally do NOT pre-insert into InstanceMap here. -- The VFS watcher will process the written files and assign a -- server-side Ref ID. Any Ref properties targeting this instance @@ -818,6 +832,7 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) -- Apply Rojo addition to Studio Log.info("[Push] Add: {}.{}", parentPath, instanceName) pushPatch.added[id] = change + table.insert(stageIds, id) elseif selection == "pull" and self.__twoWaySync then -- Don't add in Studio, remove from Rojo -- Log at info level since this is a major file deletion operation @@ -846,19 +861,27 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) self:__applyPatch(pushPatch) end - -- Send pull items back to Rojo server - if self.__twoWaySync and not PatchSet.isEmpty(pullPatch) then - local addCount = 0 - for _ in pairs(pullPatch.added) do - addCount += 1 + -- Send pull items + stage requests to Rojo server + local hasPullChanges = self.__twoWaySync and not PatchSet.isEmpty(pullPatch) + local hasStageIds = #stageIds > 0 + + if hasPullChanges or hasStageIds then + if hasPullChanges then + local addCount = 0 + for _ in pairs(pullPatch.added) do + addCount += 1 + end + Log.info( + "Sending to Rojo: {} file creations, {} file deletions, {} updates", + addCount, + #pullPatch.removed, + #pullPatch.updated + ) end - Log.info( - "Sending to Rojo: {} file creations, {} file deletions, {} updates", - addCount, - #pullPatch.removed, - #pullPatch.updated - ) - return self.__apiContext:write(pullPatch) + if hasStageIds then + Log.info("Requesting git stage for {} files", #stageIds) + end + return self.__apiContext:write(pullPatch, stageIds) end return Promise.resolve() diff --git a/src/change_processor.rs b/src/change_processor.rs index 99e54606..86fc1419 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -2,6 +2,7 @@ use crossbeam_channel::{select, Receiver, RecvError, Sender}; use jod_thread::JoinHandle; use memofs::{IoResultExt, Vfs, VfsEvent}; use rbx_dom_weak::types::{Ref, Variant}; +use std::collections::HashSet; use std::fmt; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -83,6 +84,7 @@ impl ChangeProcessor { ref_path_index: Arc>, project_root: PathBuf, critical_error_receiver: Option>, + git_repo_root: Option, ) -> Self { let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1); let vfs_receiver = vfs.event_receiver(); @@ -101,6 +103,7 @@ impl ChangeProcessor { suppressed_paths, project_root, ref_path_index, + git_repo_root, }; let job_thread = jod_thread::Builder::new() @@ -245,6 +248,10 @@ struct JobThreadContext { /// Index of meta/model files that contain `Rojo_Ref_*` attributes. /// Shared with ApiService for efficient rename path updates. ref_path_index: Arc>, + + /// Git repository root, if the project is in a git repo. + /// Used for auto-staging Source writes. + git_repo_root: Option, } impl JobThreadContext { @@ -1005,6 +1012,7 @@ impl JobThreadContext { added_instances: patch_set.added_instances, removed_instances: patch_set.removed_instances, updated_instances: Vec::new(), + stage_ids: HashSet::new(), }; let applied = apply_patch_set(&mut tree, structural_patch); @@ -1812,6 +1820,10 @@ impl JobThreadContext { id, 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 { log::warn!("Cannot change Source to non-string value."); diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 00000000..0a25650f --- /dev/null +++ b/src/git.rs @@ -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 { + 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> { + 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 { + 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 { + 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 { + 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>, + project_root: &Path, +) -> Option { + 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 = { + 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 = resolved.iter().map(|ri| ri.id).collect(); + let mut script_committed_hashes: HashMap> = 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, + }) +} diff --git a/src/lib.rs b/src/lib.rs index 45d7302f..ed8541cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod cli; mod tree_view; mod change_processor; +pub mod git; mod glob; mod json; mod lua_ast; diff --git a/src/serve_session.rs b/src/serve_session.rs index 227b7b40..46788189 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -119,6 +119,10 @@ pub struct ServeSession { /// Shared between ApiService (writes) and ChangeProcessor (rename updates). /// `None` for oneshot sessions. ref_path_index: Option>>, + + /// 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, } impl ServeSession { @@ -172,6 +176,8 @@ impl ServeSession { let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::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"); let change_processor = ChangeProcessor::start( Arc::clone(&tree), @@ -182,6 +188,7 @@ impl ServeSession { Arc::clone(&ref_path_index), root_project.folder_location().to_path_buf(), critical_error_receiver, + git_repo_root.clone(), ); Ok(Self { @@ -195,6 +202,7 @@ impl ServeSession { vfs, suppressed_paths: Some(suppressed_paths), ref_path_index: Some(ref_path_index), + git_repo_root, }) } @@ -220,6 +228,7 @@ impl ServeSession { vfs: Arc::new(vfs), suppressed_paths: None, ref_path_index: None, + git_repo_root: None, }) } @@ -312,6 +321,10 @@ impl ServeSession { 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 { &self.root_project } diff --git a/src/snapshot/patch.rs b/src/snapshot/patch.rs index fb984442..7a4195ea 100644 --- a/src/snapshot/patch.rs +++ b/src/snapshot/patch.rs @@ -1,5 +1,7 @@ //! Defines the data structures used for describing instance patches. +use std::collections::HashSet; + use rbx_dom_weak::{ types::{Ref, Variant}, HashMapExt as _, Ustr, UstrMap, @@ -18,6 +20,10 @@ pub struct PatchSet { pub removed_instances: Vec, pub added_instances: Vec, pub updated_instances: Vec, + /// 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, } impl PatchSet { @@ -26,6 +32,7 @@ impl PatchSet { removed_instances: Vec::new(), added_instances: Vec::new(), updated_instances: Vec::new(), + stage_ids: HashSet::new(), } } } diff --git a/src/snapshot/patch_apply.rs b/src/snapshot/patch_apply.rs index 00326a19..f8a1031b 100644 --- a/src/snapshot/patch_apply.rs +++ b/src/snapshot/patch_apply.rs @@ -391,6 +391,7 @@ mod test { parent_id: root_id, instance: snapshot.clone(), }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -438,6 +439,7 @@ mod test { let patch_set = PatchSet { updated_instances: vec![patch], + stage_ids: HashSet::new(), ..Default::default() }; diff --git a/src/snapshot/patch_compute.rs b/src/snapshot/patch_compute.rs index c4602bf1..56c5bd58 100644 --- a/src/snapshot/patch_compute.rs +++ b/src/snapshot/patch_compute.rs @@ -347,6 +347,7 @@ mod test { }], added_instances: Vec::new(), removed_instances: Vec::new(), + stage_ids: std::collections::HashSet::new(), }; assert_eq!(patch_set, expected_patch_set); @@ -397,6 +398,7 @@ mod test { }], updated_instances: Vec::new(), removed_instances: Vec::new(), + stage_ids: std::collections::HashSet::new(), }; assert_eq!(patch_set, expected_patch_set); diff --git a/src/snapshot/tests/apply.rs b/src/snapshot/tests/apply.rs index d3602bb3..2dc8cb4c 100644 --- a/src/snapshot/tests/apply.rs +++ b/src/snapshot/tests/apply.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use insta::assert_yaml_snapshot; use rbx_dom_weak::{ustr, UstrMap}; @@ -23,6 +25,7 @@ fn set_name_and_class_name() { changed_properties: Default::default(), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -50,6 +53,7 @@ fn add_property() { changed_properties: UstrMap::from_iter([(ustr("Foo"), Some("Value of Foo".into()))]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -89,6 +93,7 @@ fn remove_property() { changed_properties: UstrMap::from_iter([(ustr("Foo"), None)]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -144,6 +149,7 @@ fn apply_ref_property_update() { )]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -203,6 +209,7 @@ fn apply_nil_ref_skips_in_forward_sync() { )]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -258,6 +265,7 @@ fn apply_ref_property_removal() { changed_properties: UstrMap::from_iter([(ustr("PrimaryPart"), None)]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; @@ -314,6 +322,7 @@ fn apply_multiple_ref_property_updates() { changed_metadata: None, }, ], + stage_ids: HashSet::new(), ..Default::default() }; @@ -365,6 +374,7 @@ fn apply_ref_to_nonexistent_instance() { )]), changed_metadata: None, }], + stage_ids: HashSet::new(), ..Default::default() }; diff --git a/src/web/api.rs b/src/web/api.rs index cd15b96d..3ace00d5 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -264,8 +264,7 @@ impl ApiService { /// Get a summary of information about the server async fn handle_api_rojo(&self) -> Response> { - let tree = self.serve_session.tree(); - let root_instance_id = tree.get_root_id(); + let root_instance_id = self.serve_session.tree().get_root_id(); let ignore_hidden_services = self.serve_session.ignore_hidden_services(); let visible_services = if ignore_hidden_services { @@ -274,6 +273,11 @@ impl ApiService { Vec::new() }; + let git_metadata = crate::git::compute_git_metadata( + &self.serve_session.tree_handle(), + self.serve_session.root_dir(), + ); + msgpack_ok(&ServerInfoResponse { server_version: SERVER_VERSION.to_owned(), protocol_version: PROTOCOL_VERSION, @@ -288,6 +292,7 @@ impl ApiService { sync_source_only: true, ignore_hidden_services, visible_services, + git_metadata, }) } @@ -735,14 +740,38 @@ impl ApiService { }) .collect(); + let stage_ids: HashSet = request.stage_ids.into_iter().collect(); + tree_mutation_sender .send(PatchSet { removed_instances: actually_removed, added_instances, updated_instances, + stage_ids: stage_ids.clone(), }) .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 = 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 }) } diff --git a/src/web/interface.rs b/src/web/interface.rs index 4feb5e61..dd6e4281 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -152,6 +152,16 @@ fn property_filter(value: Option<&Variant>) -> bool { 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, + /// 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>, +} + /// Response body from /api/rojo #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -202,6 +212,8 @@ pub struct ServerInfoResponse { /// Services not in this list should be ignored during sync operations. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub visible_services: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_metadata: Option, } // Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua @@ -287,6 +299,9 @@ pub struct WriteRequest { #[serde(default)] pub added: HashMap, pub updated: Vec, + /// Instance IDs whose backing files should be staged via git add. + #[serde(default)] + pub stage_ids: Vec, } #[derive(Debug, Serialize, Deserialize)] diff --git a/tests/tests/connected_mode.rs b/tests/tests/connected_mode.rs index 8715639f..3493780e 100644 --- a/tests/tests/connected_mode.rs +++ b/tests/tests/connected_mode.rs @@ -849,6 +849,7 @@ fn api_write_then_filesystem_change() { changed_properties: props, changed_metadata: None, }], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/tests/tests/serve.rs b/tests/tests/serve.rs index 907c6883..d57f3603 100644 --- a/tests/tests/serve.rs +++ b/tests/tests/serve.rs @@ -780,6 +780,7 @@ fn api_write_existing_instance() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session diff --git a/tests/tests/syncback_format_transitions.rs b/tests/tests/syncback_format_transitions.rs index 1acacdc9..79bbc163 100644 --- a/tests/tests/syncback_format_transitions.rs +++ b/tests/tests/syncback_format_transitions.rs @@ -75,6 +75,7 @@ fn send_write_request( removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session @@ -904,6 +905,7 @@ fn rapid_syncs_dont_create_duplicates() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session @@ -1179,6 +1181,7 @@ fn scripts_only_mode_rapid_syncs() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session @@ -1802,6 +1805,7 @@ fn sync_immediate_double_sync() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -1826,6 +1830,7 @@ fn sync_immediate_double_sync() { removed: vec![], added: added_map2, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request2).unwrap(); @@ -1871,6 +1876,7 @@ fn stress_test_50_rapid_syncs() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); // No delay between syncs! diff --git a/tests/tests/two_way_sync.rs b/tests/tests/two_way_sync.rs index 3cb44622..cc712d96 100644 --- a/tests/tests/two_way_sync.rs +++ b/tests/tests/two_way_sync.rs @@ -97,6 +97,7 @@ fn send_update( removed: vec![], added: HashMap::new(), updated: vec![update], + stage_ids: Vec::new(), }; session .post_api_write(&write_request) @@ -115,6 +116,7 @@ fn send_removal( removed: ids, added: HashMap::new(), updated: vec![], + stage_ids: Vec::new(), }; session .post_api_write(&write_request) @@ -704,6 +706,7 @@ fn echo_suppression_prevents_redundant_patches() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -1127,6 +1130,7 @@ fn add_child_converts_standalone_to_directory() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -1279,6 +1283,7 @@ fn add_child_to_encoded_module_creates_encoded_directory() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -1352,6 +1357,7 @@ fn add_child_to_encoded_script_strips_suffix_correctly() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -1418,6 +1424,7 @@ fn add_child_to_encoded_model_creates_encoded_directory() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -1521,6 +1528,7 @@ fn send_update_fast( removed: vec![], added: HashMap::new(), updated: vec![update], + stage_ids: Vec::new(), }; session .post_api_write(&write_request) @@ -1539,6 +1547,7 @@ fn send_update_no_wait( removed: vec![], added: HashMap::new(), updated: vec![update], + stage_ids: Vec::new(), }; session .post_api_write(&write_request) @@ -1556,6 +1565,7 @@ fn send_removal_fast( removed: ids, added: HashMap::new(), updated: vec![], + stage_ids: Vec::new(), }; session .post_api_write(&write_request) @@ -2198,6 +2208,7 @@ fn multi_instance_source_update_single_request() { removed: vec![], added: HashMap::new(), updated: updates, + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); wait_for_settle(); @@ -2234,6 +2245,7 @@ fn multi_instance_rename_single_request() { removed: vec![], added: HashMap::new(), updated: updates, + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); wait_for_settle(); @@ -2337,6 +2349,7 @@ fn echo_suppression_rapid_adds_10x() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); } @@ -2409,6 +2422,7 @@ fn echo_suppression_mixed_operations() { removed, added: added_map, updated: updates, + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); wait_for_settle(); @@ -2758,6 +2772,7 @@ fn fuzz_multi_instance_15_iterations() { removed: vec![], added: HashMap::new(), updated: updates, + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(100)); @@ -4159,6 +4174,7 @@ fn multi_instance_combined_rename_class_source_batch() { removed: vec![], added: HashMap::new(), updated: updates, + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); wait_for_settle(); @@ -5126,6 +5142,7 @@ fn add_instance_with_forbidden_chars_creates_slug_and_meta() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -5191,6 +5208,7 @@ fn add_two_colliding_instances_deduplicates() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -5470,6 +5488,7 @@ fn add_three_colliding_instances_deduplicates() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -5636,6 +5655,7 @@ fn add_case_insensitive_colliding_instances() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -6753,6 +6773,7 @@ fn ref_to_instance_added_in_same_request() { changed_properties: props, changed_metadata: None, }], + stage_ids: Vec::new(), }; session @@ -7266,6 +7287,7 @@ fn ref_same_batch_add_target_includes_extension() { changed_properties: update_props, changed_metadata: None, }], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -7320,6 +7342,7 @@ fn ref_same_batch_add_module_target_includes_extension() { changed_properties: update_props, changed_metadata: None, }], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -7367,6 +7390,7 @@ fn ref_same_batch_add_folder_target_no_extension() { changed_properties: update_props, changed_metadata: None, }], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -7430,6 +7454,7 @@ fn delete_deduped_instance_group_to_1_cleanup() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -7515,6 +7540,7 @@ fn delete_base_deduped_instance_promotes_lowest() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -7593,6 +7619,7 @@ fn delete_middle_deduped_instance_tolerates_gap() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -7704,6 +7731,7 @@ fn add_same_named_folders_deduplicates() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -7844,6 +7872,7 @@ fn ref_through_deduped_instance() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); thread::sleep(Duration::from_millis(500)); @@ -7963,6 +7992,7 @@ fn ref_path_updated_after_dedup_cleanup() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap(); @@ -8095,6 +8125,7 @@ fn rename_ref_target_updates_ref_paths() { removed: vec![], added: added_map, updated: vec![], + stage_ids: Vec::new(), }; session.post_api_write(&write_request).unwrap();