mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-06-13 15:21:10 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
93f808db28
|
|||
|
110b9f0df3
|
|||
|
4fce3461f3
|
|||
|
917d17a738
|
|||
|
85bb5ec3f2
|
|||
|
14bbdaf560
|
|||
|
667683d3b3
|
|||
|
5b1b5db06c
|
|||
|
d7a9ce55db
|
|||
|
33dd0f5ed1
|
|||
|
996113b177
|
|||
|
95fe993de3
|
|||
|
4ca26efccb
|
|||
|
ce0db54e0a
|
|||
|
b8106354b0
|
|||
|
c552fdc52e
|
|||
|
0dc37ac848
|
|||
|
891b74b135
|
|||
| 18fdbce8b0 | |||
| a6e9939d6c | |||
|
5957368c04
|
|||
|
78916c8a63
|
|||
|
791ccfcfd1
|
|||
|
3500ebe02a
|
|||
|
8053909bd0
|
|||
|
0e1364945f
|
|||
| 3a6aae65f7 | |||
| d13d229eef | |||
| 9a485d88ce | |||
|
020d72faef
|
|||
|
60d150f4c6
|
|||
|
73dab330b5
|
|||
|
790312a5b0
|
|||
|
5c396322d9
|
|||
|
37e44e474a
|
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -45,13 +45,6 @@ jobs:
|
|||||||
name: Rojo.rbxm
|
name: Rojo.rbxm
|
||||||
path: Rojo.rbxm
|
path: Rojo.rbxm
|
||||||
|
|
||||||
- name: Upload Plugin to Roblox
|
|
||||||
env:
|
|
||||||
RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }}
|
|
||||||
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }}
|
|
||||||
RBX_PLACE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }}
|
|
||||||
run: lune run upload-plugin Rojo.rbxm
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ["create-release"]
|
needs: ["create-release"]
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -19,6 +19,3 @@
|
|||||||
[submodule "plugin/Packages/msgpack-luau"]
|
[submodule "plugin/Packages/msgpack-luau"]
|
||||||
path = plugin/Packages/msgpack-luau
|
path = plugin/Packages/msgpack-luau
|
||||||
url = https://github.com/cipharius/msgpack-luau/
|
url = https://github.com/cipharius/msgpack-luau/
|
||||||
[submodule ".lune/opencloud-execute"]
|
|
||||||
path = .lune/opencloud-execute
|
|
||||||
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
return {
|
|
||||||
luau = {
|
|
||||||
languagemode = "strict",
|
|
||||||
aliases = {
|
|
||||||
lune = "~/.lune/.typedefs/0.10.4/",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Submodule .lune/opencloud-execute deleted from 8ae86dd3ad
@@ -1,51 +0,0 @@
|
|||||||
local args: any = ...
|
|
||||||
assert(args, "no arguments passed to script")
|
|
||||||
|
|
||||||
local input: buffer = args.BinaryInput
|
|
||||||
|
|
||||||
local AssetService = game:GetService("AssetService")
|
|
||||||
local SerializationService = game:GetService("SerializationService")
|
|
||||||
local EncodingService = game:GetService("EncodingService")
|
|
||||||
|
|
||||||
local input_hash: buffer = EncodingService:ComputeBufferHash(input, Enum.HashAlgorithm.Sha256)
|
|
||||||
local hex_hash: { string } = table.create(buffer.len(input_hash))
|
|
||||||
for i = 0, buffer.len(input_hash) - 1 do
|
|
||||||
table.insert(hex_hash, string.format("%02x", buffer.readu8(input_hash, i)))
|
|
||||||
end
|
|
||||||
|
|
||||||
print(`Deserializing plugin file (size: {buffer.len(input)} bytes, hash: {table.concat(hex_hash, "")})`)
|
|
||||||
local plugin = SerializationService:DeserializeInstancesAsync(input)[1]
|
|
||||||
|
|
||||||
local UploadDetails = require(plugin.UploadDetails) :: any
|
|
||||||
local PLUGIN_ID = UploadDetails.assetId
|
|
||||||
local PLUGIN_NAME = UploadDetails.name
|
|
||||||
local PLUGIN_DESCRIPTION = UploadDetails.description
|
|
||||||
local PLUGIN_CREATOR_ID = UploadDetails.creatorId
|
|
||||||
local PLUGIN_CREATOR_TYPE = UploadDetails.creatorType
|
|
||||||
|
|
||||||
assert(typeof(PLUGIN_ID) == "number", "UploadDetails did not contain a number field 'assetId'")
|
|
||||||
assert(typeof(PLUGIN_NAME) == "string", "UploadDetails did not contain a string field 'name'")
|
|
||||||
assert(typeof(PLUGIN_DESCRIPTION) == "string", "UploadDetails did not contain a string field 'description'")
|
|
||||||
assert(typeof(PLUGIN_CREATOR_ID) == "number", "UploadDetails did not contain a number field 'creatorId'")
|
|
||||||
assert(typeof(PLUGIN_CREATOR_TYPE) == "string", "UploadDetails did not contain a string field 'creatorType'")
|
|
||||||
assert(
|
|
||||||
Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE) ~= nil,
|
|
||||||
"UploadDetails field 'creatorType' was not a valid member of Enum.AssetCreatorType"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(`Uploading to {PLUGIN_ID}`)
|
|
||||||
print(`Plugin Name: {PLUGIN_NAME}`)
|
|
||||||
print(`Plugin Description: {PLUGIN_DESCRIPTION}`)
|
|
||||||
|
|
||||||
local result, version_or_err = AssetService:CreateAssetVersionAsync(plugin, Enum.AssetType.Plugin, PLUGIN_ID, {
|
|
||||||
["Name"] = PLUGIN_NAME,
|
|
||||||
["Description"] = PLUGIN_DESCRIPTION,
|
|
||||||
["CreatorId"] = PLUGIN_CREATOR_ID,
|
|
||||||
["CreatorType"] = Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE),
|
|
||||||
})
|
|
||||||
|
|
||||||
if result ~= Enum.CreateAssetResult.Success then
|
|
||||||
error(`Plugin failed to upload because: {result.Name} - {version_or_err}`)
|
|
||||||
end
|
|
||||||
|
|
||||||
print(`Plugin uploaded successfully. New version is {version_or_err}.`)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
local fs = require("@lune/fs")
|
|
||||||
local process = require("@lune/process")
|
|
||||||
local stdio = require("@lune/stdio")
|
|
||||||
|
|
||||||
local luau_execute = require("./opencloud-execute")
|
|
||||||
|
|
||||||
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
|
|
||||||
local PLACE_ID = process.env["RBX_PLACE_ID"]
|
|
||||||
|
|
||||||
local version_string = fs.readFile("plugin/Version.txt")
|
|
||||||
local versions = { string.match(version_string, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") }
|
|
||||||
if versions[4] ~= "" then
|
|
||||||
print("This release is a pre-release. Skipping uploading plugin.")
|
|
||||||
process.exit(0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local plugin_path = process.args[1]
|
|
||||||
assert(
|
|
||||||
typeof(plugin_path) == "string",
|
|
||||||
"no plugin path provided, expected usage is `lune run upload-plugin [PATH TO RBXM]`."
|
|
||||||
)
|
|
||||||
|
|
||||||
-- For local testing
|
|
||||||
if process.env["CI"] ~= "true" then
|
|
||||||
local rojo = process.exec("rojo", { "build", "plugin.project.json", "--output", plugin_path })
|
|
||||||
if not rojo.ok then
|
|
||||||
stdio.ewrite("plugin upload failed because: could not build plugin.rbxm\n\n")
|
|
||||||
stdio.ewrite(rojo.stderr)
|
|
||||||
stdio.ewrite("\n")
|
|
||||||
process.exit(1)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
assert(fs.isFile(plugin_path), `Plugin file did not exist at {plugin_path}`)
|
|
||||||
end
|
|
||||||
local plugin_content = fs.readFile(plugin_path)
|
|
||||||
|
|
||||||
local engine_script = fs.readFile(".lune/scripts/plugin-upload.luau")
|
|
||||||
|
|
||||||
print("Creating task to upload plugin")
|
|
||||||
local task = luau_execute.create_task_latest(UNIVERSE_ID, PLACE_ID, engine_script, 300, false, plugin_content)
|
|
||||||
|
|
||||||
print("Waiting for task to finish")
|
|
||||||
local success = luau_execute.await_finish(task)
|
|
||||||
if not success then
|
|
||||||
local error = luau_execute.get_error(task)
|
|
||||||
assert(error, "could not fetch error from task")
|
|
||||||
stdio.ewrite("plugin upload failed because: task did not finish successfully\n\n")
|
|
||||||
stdio.ewrite(error.code)
|
|
||||||
stdio.ewrite("\n")
|
|
||||||
stdio.ewrite(error.message)
|
|
||||||
stdio.ewrite("\n")
|
|
||||||
process.exit(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
print("Output from task:\n")
|
|
||||||
for _, log in luau_execute.get_structured_logs(task) do
|
|
||||||
if log.messageType == "ERROR" then
|
|
||||||
stdio.write(stdio.color("red"))
|
|
||||||
stdio.write(log.message)
|
|
||||||
stdio.write("\n")
|
|
||||||
stdio.write(stdio.color("reset"))
|
|
||||||
elseif log.messageType == "INFO" then
|
|
||||||
stdio.write(stdio.color("cyan"))
|
|
||||||
stdio.write(log.message)
|
|
||||||
stdio.write("\n")
|
|
||||||
stdio.write(stdio.color("reset"))
|
|
||||||
elseif log.messageType == "WARNING" then
|
|
||||||
stdio.write(stdio.color("yellow"))
|
|
||||||
stdio.write(log.message)
|
|
||||||
stdio.write("\n")
|
|
||||||
stdio.write(stdio.color("reset"))
|
|
||||||
else
|
|
||||||
stdio.write(stdio.color("reset"))
|
|
||||||
stdio.write(log.message)
|
|
||||||
stdio.write("\n")
|
|
||||||
stdio.write(stdio.color("reset"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -33,37 +33,23 @@ Making a new release? Simply add the new header with the version and date undern
|
|||||||
|
|
||||||
* `inf` and `nan` values in properties are now synced ([#1176])
|
* `inf` and `nan` values in properties are now synced ([#1176])
|
||||||
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
|
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
|
||||||
|
* Implemented support for the "name" property in meta/model JSON files. ([#1187])
|
||||||
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
|
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
|
||||||
* Added actors and bindable/remote event/function variants to be synced back as JSON files. ([#1199])
|
* Added actors and bindable/remote event/function variants to be synced back as JSON files. ([#1199])
|
||||||
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
|
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
|
||||||
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
|
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
|
||||||
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
|
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
|
||||||
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
|
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
|
||||||
* Fixed missing support for init.plugin.lua and init.plugin.luau. ([#1252])
|
|
||||||
* Add support for gitignore-style negation in `globIgnorePaths` and syncback's `ignorePaths` ([#1256])
|
|
||||||
* Fixed the sync fallback scrambling sibling order; replacements are now re-parented ancestors-first and in their original child order. ([#1265])
|
|
||||||
* Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266])
|
|
||||||
* Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267])
|
|
||||||
* Fixed `/api/serialize` returning success when a requested instance ID is missing from the serve session tree. ([#1272])
|
|
||||||
* `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. The accepted hosts can be extended with the `--allowed-hosts` option or a project's `serveAllowedHosts` field, for example to reach a network-exposed server by hostname. ([#1270])
|
|
||||||
* Fixed syncback not removing stale `$properties` entries when Studio resets a property to its engine default. ([#1244])
|
|
||||||
|
|
||||||
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
|
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
|
||||||
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
|
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
|
||||||
|
[#1187]: https://github.com/rojo-rbx/rojo/pull/1187
|
||||||
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
|
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
|
||||||
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199
|
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199
|
||||||
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
|
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
|
||||||
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
|
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
|
||||||
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
|
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
|
||||||
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
|
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
|
||||||
[#1252]: https://github.com/rojo-rbx/rojo/pull/1252
|
|
||||||
[#1256]: https://github.com/rojo-rbx/rojo/pull/1256
|
|
||||||
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265
|
|
||||||
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266
|
|
||||||
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
|
|
||||||
[#1272]: https://github.com/rojo-rbx/rojo/pull/1272
|
|
||||||
[#1270]: https://github.com/rojo-rbx/rojo/pull/1270
|
|
||||||
[#1244]: https://github.com/rojo-rbx/rojo/pull/1244
|
|
||||||
|
|
||||||
## [7.7.0-rc.1] (November 27th, 2025)
|
## [7.7.0-rc.1] (November 27th, 2025)
|
||||||
|
|
||||||
|
|||||||
@@ -13,27 +13,12 @@ Code contributions are welcome for features and bugs that have been reported in
|
|||||||
|
|
||||||
You'll want these tools to work on Rojo:
|
You'll want these tools to work on Rojo:
|
||||||
|
|
||||||
* Rust 1.88 or newer
|
* Latest stable Rust compiler
|
||||||
* Rustfmt and Clippy are used for code formatting and linting.
|
* Rustfmt and Clippy are used for code formatting and linting.
|
||||||
|
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
||||||
* [Rokit](https://github.com/rojo-rbx/rokit)
|
* [Rokit](https://github.com/rojo-rbx/rokit)
|
||||||
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
|
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
|
||||||
|
|
||||||
Rokit installs the pinned Rojo, Selene, StyLua, Lune, and run-in-roblox versions listed in [`rokit.toml`](rokit.toml):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rokit install
|
|
||||||
```
|
|
||||||
|
|
||||||
Before opening a pull request, run the relevant checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test
|
|
||||||
cargo fmt -- --check
|
|
||||||
cargo clippy
|
|
||||||
stylua --check plugin/src
|
|
||||||
selene plugin/src
|
|
||||||
```
|
|
||||||
|
|
||||||
When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change:
|
When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change:
|
||||||
|
|
||||||
*(Make sure you've enabled the Studio setting to reload plugins on file change!)*
|
*(Make sure you've enabled the Studio setting to reload plugins on file change!)*
|
||||||
@@ -44,7 +29,7 @@ bash scripts/watch-build-plugin.sh
|
|||||||
|
|
||||||
You can also run the plugin's unit tests with the following:
|
You can also run the plugin's unit tests with the following:
|
||||||
|
|
||||||
*(If you are not using Rokit, make sure you have `run-in-roblox` installed first!)*
|
*(Make sure you have `run-in-roblox` installed first!)*
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/unit-test-plugin.sh
|
bash scripts/unit-test-plugin.sh
|
||||||
@@ -64,26 +49,26 @@ Please file issues and we'll try to help figure out what the best way forward is
|
|||||||
|
|
||||||
## Local Development Gotchas
|
## Local Development Gotchas
|
||||||
|
|
||||||
If your build fails with an error about a missing path under `plugin/Packages`, such as `plugin/Packages/Roact`, you need to update your Git submodules.
|
If your build fails with "Error: failed to open file `D:\code\rojo\plugin\modules\roact\src`" you need to update your Git submodules.
|
||||||
Run the command and try building again: `git submodule update --init --recursive`.
|
Run the command and try building again: `git submodule update --init --recursive`.
|
||||||
|
|
||||||
## Pushing a Rojo Release
|
## Pushing a Rojo Release
|
||||||
The Rojo release process is driven by the GitHub Actions release workflow. If you need to do it, here's how:
|
The Rojo release process is pretty manual right now. If you need to do it, here's how:
|
||||||
|
|
||||||
1. Bump server version in [`Cargo.toml`](Cargo.toml)
|
1. Bump server version in [`Cargo.toml`](Cargo.toml)
|
||||||
2. Bump plugin version in [`plugin/Version.txt`](plugin/Version.txt)
|
2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
|
||||||
* The build checks that the Cargo and plugin versions match.
|
3. Run `cargo test` to update `Cargo.lock` and run tests
|
||||||
3. Run `cargo test` to update `Cargo.lock` after the version bump and run tests
|
|
||||||
4. Update [`CHANGELOG.md`](CHANGELOG.md)
|
4. Update [`CHANGELOG.md`](CHANGELOG.md)
|
||||||
5. Commit!
|
5. Commit!
|
||||||
* `git add . && git commit -m "Release vX.Y.Z"`
|
* `git add . && git commit -m "Release vX.Y.Z"`
|
||||||
6. Tag the commit
|
6. Tag the commit
|
||||||
* `git tag vX.Y.Z`
|
* `git tag vX.Y.Z`
|
||||||
7. Push commits and tags
|
7. Publish the CLI
|
||||||
* `git push && git push --tags`
|
|
||||||
8. Wait for the GitHub Actions release workflow to create the draft release and upload CLI/plugin artifacts
|
|
||||||
9. Publish the CLI crate
|
|
||||||
* `cargo publish`
|
* `cargo publish`
|
||||||
|
8. Publish the Plugin
|
||||||
|
* `cargo run -- upload plugin --asset_id 6415005344`
|
||||||
|
9. Push commits and tags
|
||||||
|
* `git push && git push --tags`
|
||||||
10. Copy GitHub release content from previous release
|
10. Copy GitHub release content from previous release
|
||||||
* Update the leading text with a summary about the release
|
* Update the leading text with a summary about the release
|
||||||
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
|
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ Rojo enables:
|
|||||||
* Versioning your game, library, or plugin using Git or another VCS
|
* Versioning your game, library, or plugin using Git or another VCS
|
||||||
* Streaming `rbxmx` and `rbxm` models into your game in real time
|
* Streaming `rbxmx` and `rbxm` models into your game in real time
|
||||||
* Packaging and deploying your project to Roblox.com from the command line
|
* Packaging and deploying your project to Roblox.com from the command line
|
||||||
* Pulling Instances from Roblox place and model files back into an existing Rojo project with `rojo syncback`
|
|
||||||
|
|
||||||
Rojo also has an optional two-way sync setting in the Studio plugin for syncing supported Studio edits back to the filesystem.
|
In the future, Rojo will be able to:
|
||||||
|
|
||||||
Some workflows, like fully automatic conversion of every existing game into a Rojo project, are still limited and may require manual project configuration.
|
* Sync instances from Roblox Studio to the filesystem
|
||||||
|
* Automatically convert your existing game to work with Rojo
|
||||||
|
* Import custom instances like MoonScript code
|
||||||
|
|
||||||
## [Documentation](https://rojo.space/docs)
|
## [Documentation](https://rojo.space/docs)
|
||||||
Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space).
|
Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space).
|
||||||
|
|||||||
1
build.rs
1
build.rs
@@ -75,7 +75,6 @@ fn main() -> Result<(), anyhow::Error> {
|
|||||||
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
|
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
|
||||||
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
|
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
|
||||||
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
|
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
|
||||||
"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
## Unreleased Changes
|
## Unreleased Changes
|
||||||
* Added `Vfs::canonicalize`. [#1201]
|
* Added `Vfs::canonicalize`. [#1201]
|
||||||
* **Breaking:** `StdBackend::new` and `Vfs::new_default` now return `io::Result`, so a failure to create the filesystem watcher is reported as an error instead of panicking. The `Default` implementation for `StdBackend` has been removed as a result. [#1267]
|
|
||||||
|
|
||||||
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
|
|
||||||
|
|
||||||
## 0.3.1 (2025-11-27)
|
## 0.3.1 (2025-11-27)
|
||||||
* Added `Vfs::exists`. [#1169]
|
* Added `Vfs::exists`. [#1169]
|
||||||
|
|||||||
@@ -255,11 +255,8 @@ pub struct Vfs {
|
|||||||
|
|
||||||
impl Vfs {
|
impl Vfs {
|
||||||
/// Creates a new `Vfs` with the default backend, `StdBackend`.
|
/// Creates a new `Vfs` with the default backend, `StdBackend`.
|
||||||
///
|
pub fn new_default() -> Self {
|
||||||
/// Returns an error if the filesystem watcher could not be initialized,
|
Self::new(StdBackend::new())
|
||||||
/// which can happen in restricted or sandboxed environments.
|
|
||||||
pub fn new_default() -> io::Result<Self> {
|
|
||||||
Ok(Self::new(StdBackend::new()?))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new `Vfs` with the given backend.
|
/// Creates a new `Vfs` with the given backend.
|
||||||
@@ -642,7 +639,7 @@ mod test {
|
|||||||
let file_path = dir.path().join("file.txt");
|
let file_path = dir.path().join("file.txt");
|
||||||
fs_err::write(&file_path, contents.to_string()).unwrap();
|
fs_err::write(&file_path, contents.to_string()).unwrap();
|
||||||
|
|
||||||
let vfs = Vfs::new(StdBackend::new().unwrap());
|
let vfs = Vfs::new(StdBackend::new());
|
||||||
let canonicalized = vfs.canonicalize(&file_path).unwrap();
|
let canonicalized = vfs.canonicalize(&file_path).unwrap();
|
||||||
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
|
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -656,7 +653,7 @@ mod test {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let file_path = dir.path().join("test");
|
let file_path = dir.path().join("test");
|
||||||
|
|
||||||
let vfs = Vfs::new(StdBackend::new().unwrap());
|
let vfs = Vfs::new(StdBackend::new());
|
||||||
let err = vfs.canonicalize(&file_path).unwrap_err();
|
let err = vfs.canonicalize(&file_path).unwrap_err();
|
||||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ pub struct StdBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StdBackend {
|
impl StdBackend {
|
||||||
pub fn new() -> io::Result<StdBackend> {
|
pub fn new() -> StdBackend {
|
||||||
let (notify_tx, notify_rx) = mpsc::channel();
|
let (notify_tx, notify_rx) = mpsc::channel();
|
||||||
let watcher = watcher(notify_tx, Duration::from_millis(50)).map_err(io::Error::other)?;
|
let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap();
|
||||||
|
|
||||||
let (tx, rx) = crossbeam_channel::unbounded();
|
let (tx, rx) = crossbeam_channel::unbounded();
|
||||||
|
|
||||||
@@ -46,11 +46,11 @@ impl StdBackend {
|
|||||||
Result::<(), crossbeam_channel::SendError<VfsEvent>>::Ok(())
|
Result::<(), crossbeam_channel::SendError<VfsEvent>>::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Self {
|
||||||
watcher,
|
watcher,
|
||||||
watcher_receiver: rx,
|
watcher_receiver: rx,
|
||||||
watches: HashSet::new(),
|
watches: HashSet::new(),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,3 +134,9 @@ impl VfsBackend for StdBackend {
|
|||||||
self.watcher.unwatch(path).map_err(io::Error::other)
|
self.watcher.unwatch(path).map_err(io::Error::other)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for StdBackend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,9 +22,6 @@
|
|||||||
},
|
},
|
||||||
"Version": {
|
"Version": {
|
||||||
"$path": "plugin/Version.txt"
|
"$path": "plugin/Version.txt"
|
||||||
},
|
|
||||||
"UploadDetails": {
|
|
||||||
"$path": "plugin/UploadDetails.json"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"assetId": 13916111004,
|
|
||||||
"name": "Rojo",
|
|
||||||
"description": "The plugin portion of Rojo, a tool to enable professional tooling for Roblox developers.",
|
|
||||||
"creatorId": 32644114,
|
|
||||||
"creatorType": "Group"
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,6 @@ local Http = {}
|
|||||||
Http.Error = HttpError
|
Http.Error = HttpError
|
||||||
Http.Response = HttpResponse
|
Http.Response = HttpResponse
|
||||||
|
|
||||||
-- Monkey patch msgpack.UInt64.new to lossily convert the low and high bits of the integer
|
|
||||||
-- to a native Luau number. We should change the upstream decoder to emit a native
|
|
||||||
-- integer, once those are live.
|
|
||||||
function msgpack.UInt64.new(mostSignificantPart: number, leastSignificantPart: number): number
|
|
||||||
return (mostSignificantPart % 2 ^ 32) * 2 ^ 32 + (leastSignificantPart % 2 ^ 32)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function performRequest(requestParams)
|
local function performRequest(requestParams)
|
||||||
local requestId = lastRequestId + 1
|
local requestId = lastRequestId + 1
|
||||||
lastRequestId = requestId
|
lastRequestId = requestId
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
local TestEZ = require(ReplicatedStorage:WaitForChild("Packages", 10):WaitForChild("TestEZ", 10))
|
local TestEZ = require(ReplicatedStorage.Packages:WaitForChild("TestEZ", 10))
|
||||||
|
|
||||||
local Rojo = ReplicatedStorage:WaitForChild("Rojo", 10)
|
local Rojo = ReplicatedStorage.Rojo
|
||||||
|
|
||||||
local Settings = require(Rojo.Plugin.Settings)
|
local Settings = require(Rojo.Plugin.Settings)
|
||||||
Settings:set("logLevel", "Trace")
|
Settings:set("logLevel", "Trace")
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
--[[
|
|
||||||
Counts how many of a virtual instance's properties match the live values on a
|
|
||||||
candidate Roblox instance. `hydrate` uses this to break ties when several
|
|
||||||
existing children share the same Name and ClassName.
|
|
||||||
|
|
||||||
This mirrors the read -> decode -> compare flow that `diff` uses, reusing the
|
|
||||||
same `getProperty`, `decodeValue`, and `trueEquals` helpers.
|
|
||||||
]]
|
|
||||||
|
|
||||||
local getProperty = require(script.Parent.getProperty)
|
|
||||||
local decodeValue = require(script.Parent.decodeValue)
|
|
||||||
local trueEquals = require(script.Parent.trueEquals)
|
|
||||||
|
|
||||||
local function countMatchingProperties(instance, virtualInstance, instanceMap)
|
|
||||||
local score = 0
|
|
||||||
|
|
||||||
for propertyName, virtualValue in virtualInstance.Properties do
|
|
||||||
-- Skip refs. During hydration the instanceMap is still being built
|
|
||||||
-- top-down, so a ref may point at an instance we haven't hydrated yet
|
|
||||||
-- and therefore can't decode reliably. Refs are also a poor
|
|
||||||
-- disambiguator between same-named siblings.
|
|
||||||
if next(virtualValue) == "Ref" then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
local getSuccess, existingValue = getProperty(instance, propertyName)
|
|
||||||
if not getSuccess then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
local decodeSuccess, decodedValue = decodeValue(virtualValue, instanceMap)
|
|
||||||
if not decodeSuccess then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
if trueEquals(existingValue, decodedValue) then
|
|
||||||
score += 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return score
|
|
||||||
end
|
|
||||||
|
|
||||||
return countMatchingProperties
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
return function()
|
|
||||||
local countMatchingProperties = require(script.Parent.countMatchingProperties)
|
|
||||||
|
|
||||||
local InstanceMap = require(script.Parent.Parent.InstanceMap)
|
|
||||||
|
|
||||||
it("counts properties whose values match the instance", function()
|
|
||||||
local instance = Instance.new("StringValue")
|
|
||||||
instance.Value = "hello"
|
|
||||||
|
|
||||||
local virtualInstance = {
|
|
||||||
ClassName = "StringValue",
|
|
||||||
Name = "Value",
|
|
||||||
Properties = {
|
|
||||||
Value = { String = "hello" },
|
|
||||||
},
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("does not count properties whose values differ", function()
|
|
||||||
local instance = Instance.new("StringValue")
|
|
||||||
instance.Value = "hello"
|
|
||||||
|
|
||||||
local virtualInstance = {
|
|
||||||
ClassName = "StringValue",
|
|
||||||
Name = "Value",
|
|
||||||
Properties = {
|
|
||||||
Value = { String = "different" },
|
|
||||||
},
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("counts multiple matching properties independently", function()
|
|
||||||
local instance = Instance.new("Part")
|
|
||||||
instance.Anchored = true
|
|
||||||
instance.CanCollide = false
|
|
||||||
|
|
||||||
local virtualInstance = {
|
|
||||||
ClassName = "Part",
|
|
||||||
Name = "Part",
|
|
||||||
Properties = {
|
|
||||||
Anchored = { Bool = true },
|
|
||||||
CanCollide = { Bool = false },
|
|
||||||
},
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(2)
|
|
||||||
|
|
||||||
-- Flip one so only a single property matches.
|
|
||||||
instance.CanCollide = true
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("skips unknown properties without counting or erroring", function()
|
|
||||||
local instance = Instance.new("Folder")
|
|
||||||
|
|
||||||
local virtualInstance = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "Folder",
|
|
||||||
Properties = {
|
|
||||||
FAKE_PROPERTY = { String = "nope" },
|
|
||||||
},
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("skips Ref properties without counting or erroring", function()
|
|
||||||
local instance = Instance.new("ObjectValue")
|
|
||||||
|
|
||||||
local virtualInstance = {
|
|
||||||
ClassName = "ObjectValue",
|
|
||||||
Name = "ObjectValue",
|
|
||||||
Properties = {
|
|
||||||
-- A ref must be skipped rather than decoded: during hydration
|
|
||||||
-- the target may not be in the map yet.
|
|
||||||
Value = { Ref = "00000000000000000000000000000000" },
|
|
||||||
},
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
@@ -10,12 +10,104 @@ local invariant = require(script.Parent.Parent.invariant)
|
|||||||
local getProperty = require(script.Parent.getProperty)
|
local getProperty = require(script.Parent.getProperty)
|
||||||
local Error = require(script.Parent.Error)
|
local Error = require(script.Parent.Error)
|
||||||
local decodeValue = require(script.Parent.decodeValue)
|
local decodeValue = require(script.Parent.decodeValue)
|
||||||
local trueEquals = require(script.Parent.trueEquals)
|
|
||||||
|
|
||||||
local function isEmpty(table)
|
local function isEmpty(table)
|
||||||
return next(table) == nil
|
return next(table) == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
|
||||||
|
return math.abs(a - b) < epsilon
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trueEquals(a, b): boolean
|
||||||
|
-- Exit early for simple equality values
|
||||||
|
if a == b then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Treat nil and { Ref = "000...0" } as equal
|
||||||
|
if
|
||||||
|
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
|
||||||
|
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
|
||||||
|
then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local typeA, typeB = typeof(a), typeof(b)
|
||||||
|
|
||||||
|
-- For tables, try recursive deep equality
|
||||||
|
if typeA == "table" and typeB == "table" then
|
||||||
|
local checkedKeys = {}
|
||||||
|
for key, value in a do
|
||||||
|
checkedKeys[key] = true
|
||||||
|
if not trueEquals(value, b[key]) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for key, value in b do
|
||||||
|
if checkedKeys[key] then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
if not trueEquals(value, a[key]) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
|
||||||
|
-- For NaN, check if both values are not equal to themselves
|
||||||
|
elseif a ~= a and b ~= b then
|
||||||
|
return true
|
||||||
|
|
||||||
|
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
|
||||||
|
elseif typeA == "number" and typeB == "number" then
|
||||||
|
return fuzzyEq(a, b, 0.0001)
|
||||||
|
|
||||||
|
-- For EnumItem->number, compare the EnumItem's value
|
||||||
|
elseif typeA == "number" and typeB == "EnumItem" then
|
||||||
|
return a == b.Value
|
||||||
|
elseif typeA == "EnumItem" and typeB == "number" then
|
||||||
|
return a.Value == b
|
||||||
|
|
||||||
|
-- For Color3s, compare to RGB ints to avoid floating point inequality
|
||||||
|
elseif typeA == "Color3" and typeB == "Color3" then
|
||||||
|
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
|
||||||
|
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
|
||||||
|
return aR == bR and aG == bG and aB == bB
|
||||||
|
|
||||||
|
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
||||||
|
elseif typeA == "CFrame" and typeB == "CFrame" then
|
||||||
|
local aComponents, bComponents = { a:GetComponents() }, { b:GetComponents() }
|
||||||
|
for i, aComponent in aComponents do
|
||||||
|
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
|
||||||
|
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
||||||
|
elseif typeA == "Vector3" and typeB == "Vector3" then
|
||||||
|
local aComponents, bComponents = { a.X, a.Y, a.Z }, { b.X, b.Y, b.Z }
|
||||||
|
for i, aComponent in aComponents do
|
||||||
|
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
|
||||||
|
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
||||||
|
elseif typeA == "Vector2" and typeB == "Vector2" then
|
||||||
|
local aComponents, bComponents = { a.X, a.Y }, { b.X, b.Y }
|
||||||
|
for i, aComponent in aComponents do
|
||||||
|
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local function shouldDeleteUnknownInstances(virtualInstance)
|
local function shouldDeleteUnknownInstances(virtualInstance)
|
||||||
if virtualInstance.Metadata ~= nil then
|
if virtualInstance.Metadata ~= nil then
|
||||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||||
|
|||||||
@@ -3,22 +3,9 @@
|
|||||||
concrete instances and assigning them IDs.
|
concrete instances and assigning them IDs.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Packages = script.Parent.Parent.Parent.Packages
|
|
||||||
local Log = require(Packages.Log)
|
|
||||||
|
|
||||||
local invariant = require(script.Parent.Parent.invariant)
|
local invariant = require(script.Parent.Parent.invariant)
|
||||||
local countMatchingProperties = require(script.Parent.countMatchingProperties)
|
|
||||||
|
|
||||||
-- When several existing children share a Name and ClassName we disambiguate
|
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
|
||||||
-- them by scoring how well each one's properties match the virtual instance.
|
|
||||||
-- That scoring is far more expensive than a Name/ClassName check, so we only do
|
|
||||||
-- it for reasonably-sized groups. Larger groups (e.g. a folder with thousands of
|
|
||||||
-- identically-named parts) fall back to the original order-based matching, which
|
|
||||||
-- bounds the added work to roughly MAX_CANDIDATES_TO_SCORE^2 property reads per
|
|
||||||
-- group regardless of how large the group is.
|
|
||||||
local MAX_CANDIDATES_TO_SCORE = 32
|
|
||||||
|
|
||||||
local function hydrateInner(stats, instanceMap, virtualInstances, rootId, rootInstance)
|
|
||||||
local virtualInstance = virtualInstances[rootId]
|
local virtualInstance = virtualInstances[rootId]
|
||||||
|
|
||||||
if virtualInstance == nil then
|
if virtualInstance == nil then
|
||||||
@@ -26,163 +13,38 @@ local function hydrateInner(stats, instanceMap, virtualInstances, rootId, rootIn
|
|||||||
end
|
end
|
||||||
|
|
||||||
instanceMap:insert(rootId, rootInstance)
|
instanceMap:insert(rootId, rootInstance)
|
||||||
stats.hydrated += 1
|
|
||||||
|
|
||||||
local existingChildren = rootInstance:GetChildren()
|
local existingChildren = rootInstance:GetChildren()
|
||||||
|
|
||||||
-- Group existing children by Name then ClassName so each virtual child can
|
-- For each existing child, we'll track whether it's been paired with an
|
||||||
-- find its candidate matches without scanning every sibling. This is what
|
-- instance that the Rojo server knows about.
|
||||||
-- keeps hydration fast for parents with thousands of children. Nesting the
|
local isExistingChildVisited = {}
|
||||||
-- two tables (rather than a combined key) keeps the Name and ClassName checks
|
for i = 1, #existingChildren do
|
||||||
-- exact, with no way for one to bleed into the other.
|
isExistingChildVisited[i] = false
|
||||||
local buckets = {}
|
|
||||||
for _, childInstance in existingChildren do
|
|
||||||
-- We guard accessing Name and ClassName in order to avoid tripping over
|
|
||||||
-- children of DataModel that Rojo won't have permissions to access at all.
|
|
||||||
local accessSuccess, name, className = pcall(function()
|
|
||||||
return childInstance.Name, childInstance.ClassName
|
|
||||||
end)
|
|
||||||
if not accessSuccess then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
local bucketsByClassName = buckets[name]
|
|
||||||
if bucketsByClassName == nil then
|
|
||||||
bucketsByClassName = {}
|
|
||||||
buckets[name] = bucketsByClassName
|
|
||||||
end
|
|
||||||
|
|
||||||
local bucket = bucketsByClassName[className]
|
|
||||||
if bucket == nil then
|
|
||||||
bucket = { cursor = 1, instances = {} }
|
|
||||||
bucketsByClassName[className] = bucket
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(bucket.instances, childInstance)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Tracks which existing children have already been paired, so one instance
|
|
||||||
-- isn't matched to two different virtual instances.
|
|
||||||
local visited = {}
|
|
||||||
|
|
||||||
for _, childId in ipairs(virtualInstance.Children) do
|
for _, childId in ipairs(virtualInstance.Children) do
|
||||||
local virtualChild = virtualInstances[childId]
|
local virtualChild = virtualInstances[childId]
|
||||||
|
|
||||||
local bucketsByClassName = buckets[virtualChild.Name]
|
for childIndex, childInstance in existingChildren do
|
||||||
local bucket = bucketsByClassName and bucketsByClassName[virtualChild.ClassName]
|
if not isExistingChildVisited[childIndex] then
|
||||||
if bucket == nil then
|
-- We guard accessing Name and ClassName in order to avoid
|
||||||
-- No existing instance matches; diff will mark this id for creation.
|
-- tripping over children of DataModel that Rojo won't have
|
||||||
Log.trace(
|
-- permissions to access at all.
|
||||||
"hydrate: no existing instance matches {} ({}) for id {}",
|
local accessSuccess, name, className = pcall(function()
|
||||||
virtualChild.Name,
|
return childInstance.Name, childInstance.ClassName
|
||||||
virtualChild.ClassName,
|
end)
|
||||||
childId
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
local instances = bucket.instances
|
-- This rule is very conservative and could be loosened in the
|
||||||
|
-- future, or more heuristics could be introduced.
|
||||||
-- Advance past any leading children that have already been paired. The
|
if accessSuccess and name == virtualChild.Name and className == virtualChild.ClassName then
|
||||||
-- cursor makes order-based matching amortized O(1) per child even for
|
isExistingChildVisited[childIndex] = true
|
||||||
-- very large groups, rather than rescanning the visited prefix.
|
hydrate(instanceMap, virtualInstances, childId, childInstance)
|
||||||
while bucket.cursor <= #instances and visited[instances[bucket.cursor]] do
|
break
|
||||||
bucket.cursor += 1
|
|
||||||
end
|
|
||||||
if bucket.cursor > #instances then
|
|
||||||
-- Every matching instance has already been paired with an earlier id.
|
|
||||||
Log.trace(
|
|
||||||
"hydrate: no unpaired instance left for {} ({}) for id {}",
|
|
||||||
virtualChild.Name,
|
|
||||||
virtualChild.ClassName,
|
|
||||||
childId
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The cursor points at the earliest unvisited child, so the slots from
|
|
||||||
-- here to the end bound how many candidates remain. Visited children
|
|
||||||
-- after the cursor (gaps) only appear once a group is small enough to be
|
|
||||||
-- scored -- the order-based path below always takes the earliest, which
|
|
||||||
-- keeps the visited region a contiguous prefix. So whenever this count
|
|
||||||
-- exceeds the cap it is exact, and we can pick the earliest match without
|
|
||||||
-- collecting anything.
|
|
||||||
local remaining = #instances - bucket.cursor + 1
|
|
||||||
|
|
||||||
local match
|
|
||||||
if remaining > MAX_CANDIDATES_TO_SCORE then
|
|
||||||
-- Too many to score affordably; take the earliest in child order,
|
|
||||||
-- reproducing the original Name + ClassName behavior.
|
|
||||||
match = instances[bucket.cursor]
|
|
||||||
Log.trace(
|
|
||||||
"hydrate: {} candidates named {} ({}) exceeds the scoring cap of {}; matching id {} by child order",
|
|
||||||
remaining,
|
|
||||||
virtualChild.Name,
|
|
||||||
virtualChild.ClassName,
|
|
||||||
MAX_CANDIDATES_TO_SCORE,
|
|
||||||
childId
|
|
||||||
)
|
|
||||||
else
|
|
||||||
-- Collect the (at most `remaining`) unvisited candidates.
|
|
||||||
local candidates = {}
|
|
||||||
for index = bucket.cursor, #instances do
|
|
||||||
local childInstance = instances[index]
|
|
||||||
if not visited[childInstance] then
|
|
||||||
table.insert(candidates, childInstance)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if #candidates == 1 then
|
|
||||||
-- Only one candidate, so there's nothing to disambiguate.
|
|
||||||
match = candidates[1]
|
|
||||||
else
|
|
||||||
-- Break the tie by choosing the candidate whose properties best
|
|
||||||
-- match the virtual instance, falling back to the earliest in
|
|
||||||
-- child order when scores are equal.
|
|
||||||
local bestScore = -1
|
|
||||||
for _, childInstance in candidates do
|
|
||||||
local score = countMatchingProperties(childInstance, virtualChild, instanceMap)
|
|
||||||
if score > bestScore then
|
|
||||||
bestScore = score
|
|
||||||
match = childInstance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
stats.ambiguousGroups += 1
|
|
||||||
stats.candidatesScored += #candidates
|
|
||||||
Log.trace(
|
|
||||||
"hydrate: disambiguated {} candidates named {} ({}) for id {} by property match (best score {})",
|
|
||||||
#candidates,
|
|
||||||
virtualChild.Name,
|
|
||||||
virtualChild.ClassName,
|
|
||||||
childId,
|
|
||||||
bestScore
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
visited[match] = true
|
|
||||||
hydrateInner(stats, instanceMap, virtualInstances, childId, match)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
|
|
||||||
-- Tallies of the work hydration did, surfaced in a single debug log below so
|
|
||||||
-- the cost of property-based disambiguation is visible without per-node spam.
|
|
||||||
local stats = {
|
|
||||||
hydrated = 0,
|
|
||||||
ambiguousGroups = 0,
|
|
||||||
candidatesScored = 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrateInner(stats, instanceMap, virtualInstances, rootId, rootInstance)
|
|
||||||
|
|
||||||
Log.debug(
|
|
||||||
"Hydrated {} instances ({} ambiguous name+class groups, {} candidates scored)",
|
|
||||||
stats.hydrated,
|
|
||||||
stats.ambiguousGroups,
|
|
||||||
stats.candidatesScored
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
return hydrate
|
return hydrate
|
||||||
|
|||||||
@@ -126,140 +126,4 @@ return function()
|
|||||||
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
|
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
|
||||||
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
|
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("should disambiguate duplicate-named siblings by matching properties", function()
|
|
||||||
local knownInstances = InstanceMap.new()
|
|
||||||
local virtualInstances = {
|
|
||||||
ROOT = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "Root",
|
|
||||||
Properties = {},
|
|
||||||
Children = { "CHILD_A", "CHILD_B" },
|
|
||||||
},
|
|
||||||
|
|
||||||
CHILD_A = {
|
|
||||||
ClassName = "StringValue",
|
|
||||||
Name = "a",
|
|
||||||
Properties = { Value = { String = "first" } },
|
|
||||||
Children = {},
|
|
||||||
},
|
|
||||||
|
|
||||||
CHILD_B = {
|
|
||||||
ClassName = "StringValue",
|
|
||||||
Name = "a",
|
|
||||||
Properties = { Value = { String = "second" } },
|
|
||||||
Children = {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
local rootInstance = Instance.new("Folder")
|
|
||||||
|
|
||||||
-- Created in the reverse order of the virtual children, so a purely
|
|
||||||
-- order-based tiebreak would mis-pair them.
|
|
||||||
local child1 = Instance.new("StringValue")
|
|
||||||
child1.Name = "a"
|
|
||||||
child1.Value = "second"
|
|
||||||
child1.Parent = rootInstance
|
|
||||||
|
|
||||||
local child2 = Instance.new("StringValue")
|
|
||||||
child2.Name = "a"
|
|
||||||
child2.Value = "first"
|
|
||||||
child2.Parent = rootInstance
|
|
||||||
|
|
||||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
|
||||||
|
|
||||||
expect(knownInstances:size()).to.equal(3)
|
|
||||||
expect(knownInstances.fromIds["CHILD_A"]).to.equal(child2)
|
|
||||||
expect(knownInstances.fromIds["CHILD_B"]).to.equal(child1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("should fall back to child order for duplicate-named siblings with no distinguishing properties", function()
|
|
||||||
local knownInstances = InstanceMap.new()
|
|
||||||
local virtualInstances = {
|
|
||||||
ROOT = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "Root",
|
|
||||||
Properties = {},
|
|
||||||
Children = { "CHILD_A", "CHILD_B" },
|
|
||||||
},
|
|
||||||
|
|
||||||
CHILD_A = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "a",
|
|
||||||
Properties = {},
|
|
||||||
Children = {},
|
|
||||||
},
|
|
||||||
|
|
||||||
CHILD_B = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "a",
|
|
||||||
Properties = {},
|
|
||||||
Children = {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
local rootInstance = Instance.new("Folder")
|
|
||||||
|
|
||||||
local child1 = Instance.new("Folder")
|
|
||||||
child1.Name = "a"
|
|
||||||
child1.Parent = rootInstance
|
|
||||||
|
|
||||||
local child2 = Instance.new("Folder")
|
|
||||||
child2.Name = "a"
|
|
||||||
child2.Parent = rootInstance
|
|
||||||
|
|
||||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
|
||||||
|
|
||||||
expect(knownInstances:size()).to.equal(3)
|
|
||||||
-- With equal scores the earliest unvisited child wins, preserving the
|
|
||||||
-- original order-based behavior.
|
|
||||||
expect(knownInstances.fromIds["CHILD_A"]).to.equal(child1)
|
|
||||||
expect(knownInstances.fromIds["CHILD_B"]).to.equal(child2)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("should fall back to child order for very large duplicate-named groups", function()
|
|
||||||
-- More candidates than hydrate is willing to score at once. The group
|
|
||||||
-- must fall back to order-based matching, so virtual child N pairs with
|
|
||||||
-- existing child N regardless of properties.
|
|
||||||
local count = 64
|
|
||||||
|
|
||||||
local knownInstances = InstanceMap.new()
|
|
||||||
local virtualInstances = {
|
|
||||||
ROOT = {
|
|
||||||
ClassName = "Folder",
|
|
||||||
Name = "Root",
|
|
||||||
Properties = {},
|
|
||||||
Children = {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
local rootInstance = Instance.new("Folder")
|
|
||||||
|
|
||||||
local expectedInstances = {}
|
|
||||||
for i = 1, count do
|
|
||||||
local id = "CHILD_" .. i
|
|
||||||
table.insert(virtualInstances.ROOT.Children, id)
|
|
||||||
virtualInstances[id] = {
|
|
||||||
ClassName = "StringValue",
|
|
||||||
Name = "a",
|
|
||||||
-- Distinct values that, if scored, would pair by value rather
|
|
||||||
-- than by order.
|
|
||||||
Properties = { Value = { String = "value " .. i } },
|
|
||||||
Children = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
local child = Instance.new("StringValue")
|
|
||||||
child.Name = "a"
|
|
||||||
child.Value = "value " .. (count - i + 1)
|
|
||||||
child.Parent = rootInstance
|
|
||||||
expectedInstances[id] = child
|
|
||||||
end
|
|
||||||
|
|
||||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
|
||||||
|
|
||||||
expect(knownInstances:size()).to.equal(count + 1)
|
|
||||||
for id, expectedInstance in expectedInstances do
|
|
||||||
expect(knownInstances.fromIds[id]).to.equal(expectedInstance)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,14 +41,41 @@ function reifyInstanceInner(unappliedPatch, deferredRefs, instanceMap, virtualIn
|
|||||||
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
|
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Instance.new can fail if we're passing in something that can't be
|
-- Before creating a new instance, check if the parent already has an
|
||||||
-- created, like a service, something enabled with a feature flag, or
|
-- untracked child with the same Name and ClassName. This enables "late
|
||||||
-- something that requires higher security than we have.
|
-- adoption" of instances that exist in Studio but weren't in the initial
|
||||||
local createSuccess, instance = pcall(Instance.new, virtualInstance.ClassName)
|
-- Rojo tree (e.g., when using --git-since filtering). Without this,
|
||||||
|
-- newly acknowledged files would create duplicate instances.
|
||||||
|
local adoptedExisting = false
|
||||||
|
local instance = nil
|
||||||
|
|
||||||
if not createSuccess then
|
for _, child in ipairs(parentInstance:GetChildren()) do
|
||||||
addAllToPatch(unappliedPatch, virtualInstances, id)
|
local accessSuccess, name, className = pcall(function()
|
||||||
return
|
return child.Name, child.ClassName
|
||||||
|
end)
|
||||||
|
|
||||||
|
if accessSuccess
|
||||||
|
and name == virtualInstance.Name
|
||||||
|
and className == virtualInstance.ClassName
|
||||||
|
and instanceMap.fromInstances[child] == nil
|
||||||
|
then
|
||||||
|
instance = child
|
||||||
|
adoptedExisting = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not adoptedExisting then
|
||||||
|
-- Instance.new can fail if we're passing in something that can't be
|
||||||
|
-- created, like a service, something enabled with a feature flag, or
|
||||||
|
-- something that requires higher security than we have.
|
||||||
|
local createSuccess
|
||||||
|
createSuccess, instance = pcall(Instance.new, virtualInstance.ClassName)
|
||||||
|
|
||||||
|
if not createSuccess then
|
||||||
|
addAllToPatch(unappliedPatch, virtualInstances, id)
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
|
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
|
||||||
@@ -96,7 +123,9 @@ function reifyInstanceInner(unappliedPatch, deferredRefs, instanceMap, virtualIn
|
|||||||
reifyInstanceInner(unappliedPatch, deferredRefs, instanceMap, virtualInstances, childId, instance)
|
reifyInstanceInner(unappliedPatch, deferredRefs, instanceMap, virtualInstances, childId, instance)
|
||||||
end
|
end
|
||||||
|
|
||||||
instance.Parent = parentInstance
|
if not adoptedExisting then
|
||||||
|
instance.Parent = parentInstance
|
||||||
|
end
|
||||||
instanceMap:insert(id, instance)
|
instanceMap:insert(id, instance)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
--[[
|
|
||||||
Fuzzy value-equality used to compare a decoded virtual property value against
|
|
||||||
the live value read from a real instance. Shared by `diff` (to decide whether
|
|
||||||
a property changed) and `hydrate` (to score candidate instances).
|
|
||||||
]]
|
|
||||||
|
|
||||||
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
|
|
||||||
return math.abs(a - b) < epsilon
|
|
||||||
end
|
|
||||||
|
|
||||||
local function trueEquals(a, b): boolean
|
|
||||||
-- Exit early for simple equality values
|
|
||||||
if a == b then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Treat nil and { Ref = "000...0" } as equal
|
|
||||||
if
|
|
||||||
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
|
|
||||||
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
|
|
||||||
then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
local typeA, typeB = typeof(a), typeof(b)
|
|
||||||
|
|
||||||
-- For tables, try recursive deep equality
|
|
||||||
if typeA == "table" and typeB == "table" then
|
|
||||||
local checkedKeys = {}
|
|
||||||
for key, value in a do
|
|
||||||
checkedKeys[key] = true
|
|
||||||
if not trueEquals(value, b[key]) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for key, value in b do
|
|
||||||
if checkedKeys[key] then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
if not trueEquals(value, a[key]) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
|
|
||||||
-- For NaN, check if both values are not equal to themselves
|
|
||||||
elseif a ~= a and b ~= b then
|
|
||||||
return true
|
|
||||||
|
|
||||||
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
|
|
||||||
elseif typeA == "number" and typeB == "number" then
|
|
||||||
return fuzzyEq(a, b, 0.0001)
|
|
||||||
|
|
||||||
-- For EnumItem->number, compare the EnumItem's value
|
|
||||||
elseif typeA == "number" and typeB == "EnumItem" then
|
|
||||||
return a == b.Value
|
|
||||||
elseif typeA == "EnumItem" and typeB == "number" then
|
|
||||||
return a.Value == b
|
|
||||||
|
|
||||||
-- For Color3s, compare to RGB ints to avoid floating point inequality
|
|
||||||
elseif typeA == "Color3" and typeB == "Color3" then
|
|
||||||
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
|
|
||||||
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
|
|
||||||
return aR == bR and aG == bG and aB == bB
|
|
||||||
|
|
||||||
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
|
||||||
elseif typeA == "CFrame" and typeB == "CFrame" then
|
|
||||||
local aComponents, bComponents = { a:GetComponents() }, { b:GetComponents() }
|
|
||||||
for i, aComponent in aComponents do
|
|
||||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
|
|
||||||
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
|
||||||
elseif typeA == "Vector3" and typeB == "Vector3" then
|
|
||||||
local aComponents, bComponents = { a.X, a.Y, a.Z }, { b.X, b.Y, b.Z }
|
|
||||||
for i, aComponent in aComponents do
|
|
||||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
|
|
||||||
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
|
|
||||||
elseif typeA == "Vector2" and typeB == "Vector2" then
|
|
||||||
local aComponents, bComponents = { a.X, a.Y }, { b.X, b.Y }
|
|
||||||
for i, aComponent in aComponents do
|
|
||||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
return trueEquals
|
|
||||||
@@ -18,7 +18,6 @@ local PatchSet = require(script.Parent.PatchSet)
|
|||||||
local Reconciler = require(script.Parent.Reconciler)
|
local Reconciler = require(script.Parent.Reconciler)
|
||||||
local strict = require(script.Parent.strict)
|
local strict = require(script.Parent.strict)
|
||||||
local Settings = require(script.Parent.Settings)
|
local Settings = require(script.Parent.Settings)
|
||||||
local orderSwaps = require(script.Parent.orderSwaps)
|
|
||||||
|
|
||||||
local Status = strict("Session.Status", {
|
local Status = strict("Session.Status", {
|
||||||
NotStarted = "NotStarted",
|
NotStarted = "NotStarted",
|
||||||
@@ -321,14 +320,6 @@ function ServeSession:__replaceInstances(idList)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Roblox appends to GetChildren() on every reparent, so the order in which
|
|
||||||
-- we re-parent replacements determines their final sibling order.
|
|
||||||
-- We process ancestors before descendants (so each replacement's
|
|
||||||
-- parent already exists when we re-parent it) and siblings in their original
|
|
||||||
-- GetChildren() order. Because the loop below moves the old instance's
|
|
||||||
-- children into the replacement *before* re-parenting the replacement, this
|
|
||||||
-- rebuilds GetChildren() exactly as it was before the swap.
|
|
||||||
local swaps = {}
|
|
||||||
for id, replacement in replacements do
|
for id, replacement in replacements do
|
||||||
local oldInstance = self.__instanceMap.fromIds[id]
|
local oldInstance = self.__instanceMap.fromIds[id]
|
||||||
if not oldInstance then
|
if not oldInstance then
|
||||||
@@ -337,16 +328,6 @@ function ServeSession:__replaceInstances(idList)
|
|||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
table.insert(swaps, {
|
|
||||||
id = id,
|
|
||||||
replacement = replacement,
|
|
||||||
oldInstance = oldInstance,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, swap in orderSwaps(swaps) do
|
|
||||||
local id, replacement, oldInstance = swap.id, swap.replacement, swap.oldInstance
|
|
||||||
|
|
||||||
self.__instanceMap:insert(id, replacement)
|
self.__instanceMap:insert(id, replacement)
|
||||||
Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
|
Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
|
||||||
local oldParent = oldInstance.Parent
|
local oldParent = oldInstance.Parent
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
--[[
|
|
||||||
Determines the order in which `ServeSession:__replaceInstances` should swap
|
|
||||||
instances so that sibling order is preserved.
|
|
||||||
|
|
||||||
Roblox appends to `GetChildren()` on every reparent, so the order in which we
|
|
||||||
re-parent replacements determines their final sibling order. To rebuild
|
|
||||||
`GetChildren()` exactly as it was before the swap we must:
|
|
||||||
|
|
||||||
* process ancestors before descendants, so each replacement's parent already
|
|
||||||
exists when we re-parent the replacement, and
|
|
||||||
* process siblings in their original `GetChildren()` order.
|
|
||||||
|
|
||||||
`swaps` is an array of `{ id, replacement, oldInstance }` entries. This sorts
|
|
||||||
the array in place (annotating each entry with `depth`/`siblingIndex`) and
|
|
||||||
returns it.
|
|
||||||
]]
|
|
||||||
local function orderSwaps(swaps)
|
|
||||||
for _, swap in swaps do
|
|
||||||
local depth = 0
|
|
||||||
local ancestor = swap.oldInstance.Parent
|
|
||||||
while ancestor ~= nil do
|
|
||||||
depth += 1
|
|
||||||
ancestor = ancestor.Parent
|
|
||||||
end
|
|
||||||
swap.depth = depth
|
|
||||||
|
|
||||||
local siblingIndex = 0
|
|
||||||
if swap.oldInstance.Parent ~= nil then
|
|
||||||
siblingIndex = table.find(swap.oldInstance.Parent:GetChildren(), swap.oldInstance) or 0
|
|
||||||
end
|
|
||||||
swap.siblingIndex = siblingIndex
|
|
||||||
end
|
|
||||||
|
|
||||||
table.sort(swaps, function(a, b)
|
|
||||||
if a.depth ~= b.depth then
|
|
||||||
return a.depth < b.depth
|
|
||||||
end
|
|
||||||
return a.siblingIndex < b.siblingIndex
|
|
||||||
end)
|
|
||||||
|
|
||||||
return swaps
|
|
||||||
end
|
|
||||||
|
|
||||||
return orderSwaps
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
return function()
|
|
||||||
local orderSwaps = require(script.Parent.orderSwaps)
|
|
||||||
|
|
||||||
it("orders same-named siblings by their original GetChildren order", function()
|
|
||||||
local parent = Instance.new("Model")
|
|
||||||
local a1 = Instance.new("Part")
|
|
||||||
a1.Name = "a"
|
|
||||||
a1.Parent = parent
|
|
||||||
local a2 = Instance.new("Part")
|
|
||||||
a2.Name = "a"
|
|
||||||
a2.Parent = parent
|
|
||||||
local a3 = Instance.new("Part")
|
|
||||||
a3.Name = "a"
|
|
||||||
a3.Parent = parent
|
|
||||||
|
|
||||||
-- Input deliberately out of sibling order.
|
|
||||||
-- orderSwaps must restore the GetChildren() order.
|
|
||||||
local ordered = orderSwaps({
|
|
||||||
{ id = "3", oldInstance = a3 },
|
|
||||||
{ id = "1", oldInstance = a1 },
|
|
||||||
{ id = "2", oldInstance = a2 },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ordered[1].oldInstance).to.equal(a1)
|
|
||||||
expect(ordered[2].oldInstance).to.equal(a2)
|
|
||||||
expect(ordered[3].oldInstance).to.equal(a3)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("orders ancestors before descendants", function()
|
|
||||||
local root = Instance.new("Model")
|
|
||||||
local child = Instance.new("Folder")
|
|
||||||
child.Parent = root
|
|
||||||
local grandchild = Instance.new("Part")
|
|
||||||
grandchild.Parent = child
|
|
||||||
|
|
||||||
local ordered = orderSwaps({
|
|
||||||
{ id = "grandchild", oldInstance = grandchild },
|
|
||||||
{ id = "child", oldInstance = child },
|
|
||||||
{ id = "root", oldInstance = root },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ordered[1].oldInstance).to.equal(root)
|
|
||||||
expect(ordered[2].oldInstance).to.equal(child)
|
|
||||||
expect(ordered[3].oldInstance).to.equal(grandchild)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it("returns a single swap unchanged", function()
|
|
||||||
local part = Instance.new("Part")
|
|
||||||
|
|
||||||
local ordered = orderSwaps({
|
|
||||||
{ id = "1", oldInstance = part },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(#ordered).to.equal(1)
|
|
||||||
expect(ordered[1].oldInstance).to.equal(part)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
source: tests/tests/build.rs
|
|
||||||
expression: contents
|
|
||||||
---
|
|
||||||
<roblox version="4">
|
|
||||||
<Item class="Folder" referent="0">
|
|
||||||
<Properties>
|
|
||||||
<string name="Name">json_model_legacy_name</string>
|
|
||||||
</Properties>
|
|
||||||
<Item class="Folder" referent="1">
|
|
||||||
<Properties>
|
|
||||||
<string name="Name">Expected Name</string>
|
|
||||||
</Properties>
|
|
||||||
</Item>
|
|
||||||
</Item>
|
|
||||||
</roblox>
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/build.rs
|
||||||
|
assertion_line: 109
|
||||||
|
expression: contents
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="DataModel" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">model_json_name_input</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Workspace" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
<bool name="NeedsPivotMigration">false</bool>
|
||||||
|
</Properties>
|
||||||
|
<Item class="StringValue" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">/Bar</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
source: tests/tests/build.rs
|
|
||||||
expression: contents
|
|
||||||
---
|
|
||||||
<roblox version="4">
|
|
||||||
<Item class="Folder" referent="0">
|
|
||||||
<Properties>
|
|
||||||
<string name="Name">plugin_init</string>
|
|
||||||
</Properties>
|
|
||||||
<Item class="Script" referent="1">
|
|
||||||
<Properties>
|
|
||||||
<string name="Name">lua</string>
|
|
||||||
<token name="RunContext">3</token>
|
|
||||||
<string name="Source"><![CDATA[return "From folder/lua/init.plugin.lua"
|
|
||||||
]]></string>
|
|
||||||
</Properties>
|
|
||||||
</Item>
|
|
||||||
<Item class="Script" referent="2">
|
|
||||||
<Properties>
|
|
||||||
<string name="Name">luau</string>
|
|
||||||
<token name="RunContext">3</token>
|
|
||||||
<string name="Source"><![CDATA[return "From folder/luau/init.plugin.luau"
|
|
||||||
]]></string>
|
|
||||||
</Properties>
|
|
||||||
</Item>
|
|
||||||
</Item>
|
|
||||||
</roblox>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/build.rs
|
||||||
|
assertion_line: 108
|
||||||
|
expression: contents
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Folder" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">slugified_name_roundtrip</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Script" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">/Script</string>
|
||||||
|
<token name="RunContext">0</token>
|
||||||
|
<string name="Source"><![CDATA[print("Hello world!")
|
||||||
|
]]></string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "json_model_legacy_name",
|
|
||||||
"tree": {
|
|
||||||
"$path": "folder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"Name": "Overridden Name",
|
|
||||||
"ClassName": "Folder"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "model_json_name_input",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"$className": "Workspace",
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "/Bar",
|
||||||
|
"className": "StringValue"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "plugin_init",
|
|
||||||
"tree": {
|
|
||||||
"$path": "folder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
return "From folder/lua/init.plugin.lua"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
return "From folder/luau/init.plugin.luau"
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "/Script"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
print("Hello world!")
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "slugified_name_roundtrip",
|
||||||
|
"tree": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"name": "/Script"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("Hello world!")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/rojo_test/syncback_util.rs
|
source: tests/rojo_test/syncback_util.rs
|
||||||
|
assertion_line: 101
|
||||||
expression: "String::from_utf8_lossy(&output.stdout)"
|
expression: "String::from_utf8_lossy(&output.stdout)"
|
||||||
---
|
---
|
||||||
Writing default.project.json
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
source: tests/rojo_test/syncback_util.rs
|
|
||||||
expression: src/ChildWithDuplicates.rbxm
|
|
||||||
---
|
|
||||||
num_types: 1
|
|
||||||
num_instances: 3
|
|
||||||
chunks:
|
|
||||||
- Inst:
|
|
||||||
type_id: 0
|
|
||||||
type_name: Folder
|
|
||||||
object_format: 0
|
|
||||||
referents:
|
|
||||||
- 0
|
|
||||||
- 1
|
|
||||||
- 2
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: AttributesSerialize
|
|
||||||
prop_type: String
|
|
||||||
values:
|
|
||||||
- ""
|
|
||||||
- ""
|
|
||||||
- ""
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: Capabilities
|
|
||||||
prop_type: SecurityCapabilities
|
|
||||||
values:
|
|
||||||
- 0
|
|
||||||
- 0
|
|
||||||
- 0
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: Name
|
|
||||||
prop_type: String
|
|
||||||
values:
|
|
||||||
- DuplicateChild
|
|
||||||
- DuplicateChild
|
|
||||||
- ChildWithDuplicates
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: DefinesCapabilities
|
|
||||||
prop_type: Bool
|
|
||||||
values:
|
|
||||||
- false
|
|
||||||
- false
|
|
||||||
- false
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: SourceAssetId
|
|
||||||
prop_type: Int64
|
|
||||||
values:
|
|
||||||
- -1
|
|
||||||
- -1
|
|
||||||
- -1
|
|
||||||
- Prop:
|
|
||||||
type_id: 0
|
|
||||||
prop_name: Tags
|
|
||||||
prop_type: String
|
|
||||||
values:
|
|
||||||
- ""
|
|
||||||
- ""
|
|
||||||
- ""
|
|
||||||
- Prnt:
|
|
||||||
version: 0
|
|
||||||
links:
|
|
||||||
- - 0
|
|
||||||
- 2
|
|
||||||
- - 1
|
|
||||||
- 2
|
|
||||||
- - 2
|
|
||||||
- -1
|
|
||||||
- End
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
---
|
---
|
||||||
source: tests/rojo_test/syncback_util.rs
|
source: tests/rojo_test/syncback_util.rs
|
||||||
|
assertion_line: 101
|
||||||
expression: "String::from_utf8_lossy(&output.stdout)"
|
expression: "String::from_utf8_lossy(&output.stdout)"
|
||||||
---
|
---
|
||||||
Writing src/ChildWithDuplicates.rbxm
|
Writing src/ChildWithDuplicates/DuplicateChild/.gitkeep
|
||||||
|
Writing src/ChildWithDuplicates/DuplicateChild1/.gitkeep
|
||||||
Writing src/ChildWithoutDuplicates/Child/.gitkeep
|
Writing src/ChildWithoutDuplicates/Child/.gitkeep
|
||||||
|
Writing src/ChildWithDuplicates/DuplicateChild
|
||||||
|
Writing src/ChildWithDuplicates/DuplicateChild1
|
||||||
Writing src/ChildWithoutDuplicates
|
Writing src/ChildWithoutDuplicates
|
||||||
Writing src/ChildWithoutDuplicates/Child
|
Writing src/ChildWithoutDuplicates/Child
|
||||||
Removing src/ChildWithDuplicates
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
source: tests/rojo_test/syncback_util.rs
|
||||||
|
assertion_line: 101
|
||||||
|
expression: "String::from_utf8_lossy(&output.stdout)"
|
||||||
|
---
|
||||||
|
Writing default.project.json
|
||||||
|
Writing src/Camera.rbxm
|
||||||
|
Writing src/Terrain.rbxm
|
||||||
|
Writing src/_Folder/init.meta.json
|
||||||
|
Writing src/_Script.meta.json
|
||||||
|
Writing src/_Script.server.luau
|
||||||
|
Writing src
|
||||||
|
Writing src/_Folder
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/foo.model.json
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"name": "/Bar",
|
||||||
|
"className": "StringValue"
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
source: tests/tests/syncback.rs
|
|
||||||
expression: default.project.json
|
|
||||||
---
|
|
||||||
{
|
|
||||||
"name": "SyncbackTest",
|
|
||||||
"tree": {
|
|
||||||
"$className": "DataModel",
|
|
||||||
"Workspace": {
|
|
||||||
"$className": "Workspace",
|
|
||||||
"TestPart": {
|
|
||||||
"$className": "Part",
|
|
||||||
"$properties": {
|
|
||||||
"Anchored": true,
|
|
||||||
"Color": {
|
|
||||||
"Color3uint8": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
255
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$properties": {
|
|
||||||
"EnableSLIMAvatars": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"ImprovedAnimationConstraint": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"ImprovedPhysicsReplication": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"LayeredClothingCacheOptimizations": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"MeshStreamingAndImprovedLods": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"NextGenerationReplication": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"PlayerScriptsUseInputActionSystem": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"UseFixedSimulation": {
|
|
||||||
"Enum": 0
|
|
||||||
},
|
|
||||||
"UseNewLuauTypeSolver": "Disabled",
|
|
||||||
"ValidateEnabledProximityPrompt": {
|
|
||||||
"Enum": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$attributes": {
|
|
||||||
"Rojo_Target_CurrentCamera": "302d573157260ee80a3baa32000003b5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/ChildWithDuplicates/DuplicateChild1/.gitkeep
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/ChildWithDuplicates/DuplicateChild/.gitkeep
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Folder.model.json
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"className": "Folder"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Folder/init.meta.json
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"name": "/Folder"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Script.meta.json
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"name": "/Script"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Script.server.luau
|
||||||
|
---
|
||||||
|
print("Hello world!")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Script/init.meta.json
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"name": "/Script"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/syncback.rs
|
||||||
|
assertion_line: 31
|
||||||
|
expression: src/_Script/init.server.luau
|
||||||
|
---
|
||||||
|
print("Hello world!")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "model_json_name",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"$className": "Workspace",
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "/Bar",
|
||||||
|
"className": "StringValue"
|
||||||
|
}
|
||||||
|
|
||||||
BIN
rojo-test/syncback-tests/model_json_name/input.rbxl
Normal file
BIN
rojo-test/syncback-tests/model_json_name/input.rbxl
Normal file
Binary file not shown.
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SyncbackTest",
|
|
||||||
"tree": {
|
|
||||||
"$className": "DataModel",
|
|
||||||
"Workspace": {
|
|
||||||
"$className": "Workspace",
|
|
||||||
"TestPart": {
|
|
||||||
"$className": "Part",
|
|
||||||
"$properties": {
|
|
||||||
"Transparency": 1.0,
|
|
||||||
"Anchored": true,
|
|
||||||
"Color": [
|
|
||||||
1.0,
|
|
||||||
0.0,
|
|
||||||
0.0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "slugified_name",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"$className": "Workspace",
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
rojo-test/syncback-tests/slugified_name/input.rbxl
Normal file
BIN
rojo-test/syncback-tests/slugified_name/input.rbxl
Normal file
Binary file not shown.
@@ -3,4 +3,3 @@ rojo = "rojo-rbx/rojo@7.5.1"
|
|||||||
selene = "Kampfkarren/selene@0.29.0"
|
selene = "Kampfkarren/selene@0.29.0"
|
||||||
stylua = "JohnnyMorganz/stylua@2.1.0"
|
stylua = "JohnnyMorganz/stylua@2.1.0"
|
||||||
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
|
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
|
||||||
lune = "lune-org/lune@0.10.4"
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
git::SharedGitFilter,
|
||||||
message_queue::MessageQueue,
|
message_queue::MessageQueue,
|
||||||
snapshot::{
|
snapshot::{
|
||||||
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
|
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
|
||||||
@@ -46,11 +47,15 @@ pub struct ChangeProcessor {
|
|||||||
impl ChangeProcessor {
|
impl ChangeProcessor {
|
||||||
/// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and
|
/// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and
|
||||||
/// outbound message queue.
|
/// outbound message queue.
|
||||||
|
///
|
||||||
|
/// If `git_filter` is provided, it will be refreshed on every VFS event
|
||||||
|
/// to ensure newly changed files are acknowledged.
|
||||||
pub fn start(
|
pub fn start(
|
||||||
tree: Arc<Mutex<RojoTree>>,
|
tree: Arc<Mutex<RojoTree>>,
|
||||||
vfs: Arc<Vfs>,
|
vfs: Arc<Vfs>,
|
||||||
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
||||||
tree_mutation_receiver: Receiver<PatchSet>,
|
tree_mutation_receiver: Receiver<PatchSet>,
|
||||||
|
git_filter: Option<SharedGitFilter>,
|
||||||
) -> 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();
|
||||||
@@ -58,6 +63,7 @@ impl ChangeProcessor {
|
|||||||
tree,
|
tree,
|
||||||
vfs,
|
vfs,
|
||||||
message_queue,
|
message_queue,
|
||||||
|
git_filter,
|
||||||
};
|
};
|
||||||
|
|
||||||
let job_thread = jod_thread::Builder::new()
|
let job_thread = jod_thread::Builder::new()
|
||||||
@@ -111,6 +117,10 @@ struct JobThreadContext {
|
|||||||
/// Whenever changes are applied to the DOM, we should push those changes
|
/// Whenever changes are applied to the DOM, we should push those changes
|
||||||
/// into this message queue to inform any connected clients.
|
/// into this message queue to inform any connected clients.
|
||||||
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
||||||
|
|
||||||
|
/// Optional Git filter for --git-since mode. When set, will be refreshed
|
||||||
|
/// on every VFS event to ensure newly changed files are acknowledged.
|
||||||
|
git_filter: Option<SharedGitFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobThreadContext {
|
impl JobThreadContext {
|
||||||
@@ -160,6 +170,14 @@ impl JobThreadContext {
|
|||||||
fn handle_vfs_event(&self, event: VfsEvent) {
|
fn handle_vfs_event(&self, event: VfsEvent) {
|
||||||
log::trace!("Vfs event: {:?}", event);
|
log::trace!("Vfs event: {:?}", event);
|
||||||
|
|
||||||
|
// If we have a git filter, refresh it to pick up any new changes.
|
||||||
|
// This ensures that files modified during the session will be acknowledged.
|
||||||
|
if let Some(ref git_filter) = self.git_filter {
|
||||||
|
if let Err(err) = git_filter.refresh() {
|
||||||
|
log::warn!("Failed to refresh git filter: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the VFS immediately with the event.
|
// Update the VFS immediately with the event.
|
||||||
self.vfs
|
self.vfs
|
||||||
.commit_event(&event)
|
.commit_event(&event)
|
||||||
@@ -200,15 +218,7 @@ impl JobThreadContext {
|
|||||||
if let Some(instance) = tree.get_instance(id) {
|
if let Some(instance) = tree.get_instance(id) {
|
||||||
if let Some(instigating_source) = &instance.metadata().instigating_source {
|
if let Some(instigating_source) = &instance.metadata().instigating_source {
|
||||||
match instigating_source {
|
match instigating_source {
|
||||||
InstigatingSource::Path(path) => {
|
InstigatingSource::Path(path) => fs::remove_file(path).unwrap(),
|
||||||
if let Err(err) = fs::remove_file(path) {
|
|
||||||
log::error!(
|
|
||||||
"Failed to remove file {}: {}",
|
|
||||||
path.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InstigatingSource::ProjectNode { .. } => {
|
InstigatingSource::ProjectNode { .. } => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Cannot remove instance {:?}, it's from a project file",
|
"Cannot remove instance {:?}, it's from a project file",
|
||||||
@@ -252,13 +262,7 @@ impl JobThreadContext {
|
|||||||
match instigating_source {
|
match instigating_source {
|
||||||
InstigatingSource::Path(path) => {
|
InstigatingSource::Path(path) => {
|
||||||
if let Some(Variant::String(value)) = changed_value {
|
if let Some(Variant::String(value)) = changed_value {
|
||||||
if let Err(err) = fs::write(path, value) {
|
fs::write(path, value).unwrap();
|
||||||
log::error!(
|
|
||||||
"Failed to write file {}: {}",
|
|
||||||
path.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Cannot change Source to non-string value.");
|
log::warn!("Cannot change Source to non-string value.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,28 +75,23 @@ impl BuildCommand {
|
|||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let project_path = resolve_path(&self.project)?;
|
let project_path = resolve_path(&self.project);
|
||||||
|
|
||||||
log::trace!("Constructing in-memory filesystem");
|
log::trace!("Constructing in-memory filesystem");
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
vfs.set_watch_enabled(self.watch);
|
vfs.set_watch_enabled(self.watch);
|
||||||
|
|
||||||
let session = ServeSession::new(vfs, project_path)?;
|
let session = ServeSession::new(vfs, project_path, None)?;
|
||||||
let mut cursor = session.message_queue().cursor();
|
let mut cursor = session.message_queue().cursor();
|
||||||
|
|
||||||
write_model(&session, &output_path, output_kind)?;
|
write_model(&session, &output_path, output_kind)?;
|
||||||
|
|
||||||
if self.watch {
|
if self.watch {
|
||||||
let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;
|
let rt = Runtime::new().unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let receiver = session.message_queue().subscribe(cursor);
|
let receiver = session.message_queue().subscribe(cursor);
|
||||||
let (new_cursor, _patch_set) = match rt.block_on(receiver) {
|
let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
|
||||||
Ok(message) => message,
|
|
||||||
// The message queue was dropped, so there is nothing left
|
|
||||||
// to watch. Stop watching gracefully.
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
cursor = new_cursor;
|
cursor = new_cursor;
|
||||||
|
|
||||||
write_model(&session, &output_path, output_kind)?;
|
write_model(&session, &output_path, output_kind)?;
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ pub struct FmtProjectCommand {
|
|||||||
|
|
||||||
impl FmtProjectCommand {
|
impl FmtProjectCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
vfs.set_watch_enabled(false);
|
vfs.set_watch_enabled(false);
|
||||||
|
|
||||||
let base_path = resolve_path(&self.project)?;
|
let base_path = resolve_path(&self.project);
|
||||||
let project = Project::load_fuzzy(&vfs, &base_path)?
|
let project = Project::load_fuzzy(&vfs, &base_path)?
|
||||||
.context("A project file is required to run 'rojo fmt-project'")?;
|
.context("A project file is required to run 'rojo fmt-project'")?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::{
|
|||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{bail, format_err, Context};
|
use anyhow::{bail, format_err};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
use fs_err::OpenOptions;
|
use fs_err::OpenOptions;
|
||||||
@@ -42,9 +42,9 @@ pub struct InitCommand {
|
|||||||
|
|
||||||
impl InitCommand {
|
impl InitCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
let template = self.kind.template()?;
|
let template = self.kind.template();
|
||||||
|
|
||||||
let base_path = resolve_path(&self.path)?;
|
let base_path = resolve_path(&self.path);
|
||||||
fs::create_dir_all(&base_path)?;
|
fs::create_dir_all(&base_path)?;
|
||||||
|
|
||||||
let canonical = fs::canonicalize(&base_path)?;
|
let canonical = fs::canonicalize(&base_path)?;
|
||||||
@@ -128,7 +128,7 @@ pub enum InitKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InitKind {
|
impl InitKind {
|
||||||
fn template(&self) -> anyhow::Result<InMemoryFs> {
|
fn template(&self) -> InMemoryFs {
|
||||||
let template_path = match self {
|
let template_path = match self {
|
||||||
Self::Place => "place",
|
Self::Place => "place",
|
||||||
Self::Model => "model",
|
Self::Model => "model",
|
||||||
@@ -136,24 +136,20 @@ impl InitKind {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
|
let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
|
||||||
.context("Rojo's templates were not properly packed into Rojo's binary. This is a bug in Rojo; please file an issue.")?;
|
.expect("Rojo's templates were not properly packed into Rojo's binary");
|
||||||
|
|
||||||
let VfsSnapshot::Dir { mut children } = snapshot else {
|
if let VfsSnapshot::Dir { mut children } = snapshot {
|
||||||
bail!("Rojo's templates were packed as a file instead of a directory. This is a bug in Rojo; please file an issue.");
|
if let Some(template) = children.remove(template_path) {
|
||||||
};
|
let mut fs = InMemoryFs::new();
|
||||||
|
fs.load_snapshot("", template)
|
||||||
let template = children.remove(template_path).ok_or_else(|| {
|
.expect("loading a template in memory should never fail");
|
||||||
format_err!(
|
fs
|
||||||
"The template for project type {:?} is missing. This is a bug in Rojo; please file an issue.",
|
} else {
|
||||||
self
|
panic!("template for project type {:?} is missing", self)
|
||||||
)
|
}
|
||||||
})?;
|
} else {
|
||||||
|
panic!("Rojo's templates were packed as a file instead of a directory")
|
||||||
let mut fs = InMemoryFs::new();
|
}
|
||||||
fs.load_snapshot("", template)
|
|
||||||
.context("Failed to load Rojo's bundled template into memory")?;
|
|
||||||
|
|
||||||
Ok(fs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ mod upload;
|
|||||||
|
|
||||||
use std::{borrow::Cow, env, path::Path, str::FromStr};
|
use std::{borrow::Cow, env, path::Path, str::FromStr};
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -126,14 +125,10 @@ pub enum Subcommand {
|
|||||||
Syncback(SyncbackCommand),
|
Syncback(SyncbackCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn resolve_path(path: &Path) -> anyhow::Result<Cow<'_, Path>> {
|
pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
Ok(Cow::Borrowed(path))
|
Cow::Borrowed(path)
|
||||||
} else {
|
} else {
|
||||||
let current_dir = env::current_dir().context(
|
Cow::Owned(env::current_dir().unwrap().join(path))
|
||||||
"Could not determine the current working directory. \
|
|
||||||
It may have been deleted, or Rojo may not have permission to access it.",
|
|
||||||
)?;
|
|
||||||
Ok(Cow::Owned(current_dir.join(path)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ fn initialize_plugin() -> anyhow::Result<ServeSession> {
|
|||||||
in_memory_fs.load_snapshot("/plugin", plugin_snapshot)?;
|
in_memory_fs.load_snapshot("/plugin", plugin_snapshot)?;
|
||||||
|
|
||||||
let vfs = Vfs::new(in_memory_fs);
|
let vfs = Vfs::new(in_memory_fs);
|
||||||
Ok(ServeSession::new(vfs, "/plugin")?)
|
Ok(ServeSession::new(vfs, "/plugin", None)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_plugin() -> anyhow::Result<()> {
|
fn install_plugin() -> anyhow::Result<()> {
|
||||||
@@ -98,5 +98,5 @@ fn uninstall_plugin() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_initialize() {
|
fn plugin_initialize() {
|
||||||
let _ = initialize_plugin().unwrap();
|
assert!(initialize_plugin().is_ok())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use clap::Parser;
|
|||||||
use memofs::Vfs;
|
use memofs::Vfs;
|
||||||
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
||||||
|
|
||||||
use crate::{serve_session::ServeSession, web::LiveServer};
|
use crate::{git::GitFilter, serve_session::ServeSession, web::LiveServer};
|
||||||
|
|
||||||
use super::{resolve_path, GlobalOptions};
|
use super::{resolve_path, GlobalOptions};
|
||||||
|
|
||||||
@@ -32,22 +32,39 @@ pub struct ServeCommand {
|
|||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
|
|
||||||
/// Extra `Host`/`Origin` values the server will accept, beyond localhost and
|
/// Only sync files that have changed since the given Git reference.
|
||||||
/// the bind address (for example a hostname like `mypc.lan`). Repeat the
|
///
|
||||||
/// option or comma-separate to allow several. When given, this overrides the
|
/// When this option is set, Rojo will only include files that have been
|
||||||
/// project's `serveAllowedHosts`. Listing any host also turns on Host/Origin
|
/// modified, added, or are untracked since the specified Git reference
|
||||||
/// validation for binds where it is otherwise off (such as `0.0.0.0`).
|
/// (e.g., "HEAD", "main", a commit hash). This is useful for working with
|
||||||
#[clap(long, value_delimiter = ',')]
|
/// large projects where you only want to sync your local changes.
|
||||||
pub allowed_hosts: Vec<String>,
|
///
|
||||||
|
/// Scripts that have not changed will still be acknowledged if modified
|
||||||
|
/// during the session, and all synced instances will have
|
||||||
|
/// ignoreUnknownInstances set to true to preserve descendants in Studio.
|
||||||
|
#[clap(long, value_name = "REF")]
|
||||||
|
pub git_since: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServeCommand {
|
impl ServeCommand {
|
||||||
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> {
|
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> {
|
||||||
let project_path = resolve_path(&self.project)?;
|
let project_path = resolve_path(&self.project);
|
||||||
|
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
|
|
||||||
let session = Arc::new(ServeSession::new(vfs, project_path)?);
|
// Set up Git filter if --git-since was specified
|
||||||
|
let git_filter = if let Some(ref base_ref) = self.git_since {
|
||||||
|
let repo_root = GitFilter::find_repo_root(&project_path)?;
|
||||||
|
log::info!(
|
||||||
|
"Git filter enabled: only syncing files changed since '{}'",
|
||||||
|
base_ref
|
||||||
|
);
|
||||||
|
Some(Arc::new(GitFilter::new(repo_root, base_ref.clone(), &project_path)?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = Arc::new(ServeSession::new(vfs, project_path, git_filter)?);
|
||||||
|
|
||||||
let ip = self
|
let ip = self
|
||||||
.address
|
.address
|
||||||
@@ -59,28 +76,27 @@ impl ServeCommand {
|
|||||||
.or_else(|| session.project_port())
|
.or_else(|| session.project_port())
|
||||||
.unwrap_or(DEFAULT_PORT);
|
.unwrap_or(DEFAULT_PORT);
|
||||||
|
|
||||||
// The CLI flag, when given, replaces the project's list rather than
|
|
||||||
// merging with it, matching how --address and --port override theirs.
|
|
||||||
let allowed_hosts = if self.allowed_hosts.is_empty() {
|
|
||||||
session.serve_allowed_hosts().to_vec()
|
|
||||||
} else {
|
|
||||||
self.allowed_hosts
|
|
||||||
};
|
|
||||||
|
|
||||||
let server = LiveServer::new(session);
|
let server = LiveServer::new(session);
|
||||||
|
|
||||||
server.start((ip, port).into(), allowed_hosts, || {
|
let _ = show_start_message(ip, port, self.git_since.as_deref(), global.color.into());
|
||||||
let _ = show_start_message(ip, port, global.color.into());
|
server.start((ip, port).into());
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io::Result<()> {
|
fn show_start_message(
|
||||||
|
bind_address: IpAddr,
|
||||||
|
port: u16,
|
||||||
|
git_since: Option<&str>,
|
||||||
|
color: ColorChoice,
|
||||||
|
) -> io::Result<()> {
|
||||||
let mut green = ColorSpec::new();
|
let mut green = ColorSpec::new();
|
||||||
green.set_fg(Some(Color::Green)).set_bold(true);
|
green.set_fg(Some(Color::Green)).set_bold(true);
|
||||||
|
|
||||||
|
let mut yellow = ColorSpec::new();
|
||||||
|
yellow.set_fg(Some(Color::Yellow)).set_bold(true);
|
||||||
|
|
||||||
let writer = BufferWriter::stdout(color);
|
let writer = BufferWriter::stdout(color);
|
||||||
let mut buffer = writer.buffer();
|
let mut buffer = writer.buffer();
|
||||||
|
|
||||||
@@ -101,27 +117,15 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io
|
|||||||
buffer.set_color(&green)?;
|
buffer.set_color(&green)?;
|
||||||
writeln!(&mut buffer, "{}", port)?;
|
writeln!(&mut buffer, "{}", port)?;
|
||||||
|
|
||||||
writeln!(&mut buffer)?;
|
if let Some(base_ref) = git_since {
|
||||||
|
|
||||||
if !bind_address.is_loopback() {
|
|
||||||
let mut warning = ColorSpec::new();
|
|
||||||
warning.set_fg(Some(Color::Yellow)).set_bold(true);
|
|
||||||
|
|
||||||
buffer.set_color(&warning)?;
|
|
||||||
writeln!(
|
|
||||||
&mut buffer,
|
|
||||||
"WARNING: This server is bound to {address_string}, which is reachable from the \
|
|
||||||
network.\n\
|
|
||||||
The serve API is unauthenticated, so anyone who can reach {address_string}:{port} \
|
|
||||||
can read\n\
|
|
||||||
and modify your project's source. Prefer binding to localhost and tunneling (e.g. \
|
|
||||||
SSH,\n\
|
|
||||||
Tailscale, or WireGuard) when you need remote access."
|
|
||||||
)?;
|
|
||||||
buffer.set_color(&ColorSpec::new())?;
|
buffer.set_color(&ColorSpec::new())?;
|
||||||
writeln!(&mut buffer)?;
|
write!(&mut buffer, " Mode: ")?;
|
||||||
|
buffer.set_color(&yellow)?;
|
||||||
|
writeln!(&mut buffer, "git-since ({})", base_ref)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeln!(&mut buffer)?;
|
||||||
|
|
||||||
buffer.set_color(&ColorSpec::new())?;
|
buffer.set_color(&ColorSpec::new())?;
|
||||||
write!(&mut buffer, "Visit ")?;
|
write!(&mut buffer, "Visit ")?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use std::{
|
|||||||
path::{self, Path, PathBuf},
|
path::{self, Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use fs_err::File;
|
use fs_err::File;
|
||||||
use memofs::Vfs;
|
use memofs::Vfs;
|
||||||
@@ -72,14 +71,14 @@ pub struct SourcemapCommand {
|
|||||||
|
|
||||||
impl SourcemapCommand {
|
impl SourcemapCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
let project_path = fs_err::canonicalize(resolve_path(&self.project)?)?;
|
let project_path = fs_err::canonicalize(resolve_path(&self.project))?;
|
||||||
|
|
||||||
log::trace!("Constructing filesystem with StdBackend");
|
log::trace!("Constructing filesystem with StdBackend");
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
vfs.set_watch_enabled(self.watch);
|
vfs.set_watch_enabled(self.watch);
|
||||||
|
|
||||||
log::trace!("Setting up session for sourcemap generation");
|
log::trace!("Setting up session for sourcemap generation");
|
||||||
let session = ServeSession::new(vfs, project_path)?;
|
let session = ServeSession::new(vfs, project_path, None)?;
|
||||||
let mut cursor = session.message_queue().cursor();
|
let mut cursor = session.message_queue().cursor();
|
||||||
|
|
||||||
let filter = if self.include_non_scripts {
|
let filter = if self.include_non_scripts {
|
||||||
@@ -101,16 +100,11 @@ impl SourcemapCommand {
|
|||||||
|
|
||||||
if self.watch {
|
if self.watch {
|
||||||
log::trace!("Setting up runtime for watch mode");
|
log::trace!("Setting up runtime for watch mode");
|
||||||
let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;
|
let rt = Runtime::new().unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let receiver = session.message_queue().subscribe(cursor);
|
let receiver = session.message_queue().subscribe(cursor);
|
||||||
let (new_cursor, patch_set) = match rt.block_on(receiver) {
|
let (new_cursor, patch_set) = rt.block_on(receiver).unwrap();
|
||||||
Ok(message) => message,
|
|
||||||
// The message queue was dropped, so there is nothing left
|
|
||||||
// to watch. Stop watching gracefully.
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
cursor = new_cursor;
|
cursor = new_cursor;
|
||||||
|
|
||||||
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
|
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
|
||||||
|
|||||||
@@ -54,12 +54,17 @@ pub struct SyncbackCommand {
|
|||||||
/// If provided, the prompt for writing to the file system is skipped.
|
/// If provided, the prompt for writing to the file system is skipped.
|
||||||
#[clap(long, short = 'y')]
|
#[clap(long, short = 'y')]
|
||||||
pub non_interactive: bool,
|
pub non_interactive: bool,
|
||||||
|
|
||||||
|
/// If provided, forces syncback to use JSON model files instead of binary
|
||||||
|
/// .rbxm files for instances that would otherwise serialize as binary.
|
||||||
|
#[clap(long)]
|
||||||
|
pub dangerously_force_json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncbackCommand {
|
impl SyncbackCommand {
|
||||||
pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
|
pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
|
||||||
let path_old = resolve_path(&self.project)?;
|
let path_old = resolve_path(&self.project);
|
||||||
let path_new = resolve_path(&self.input)?;
|
let path_new = resolve_path(&self.input);
|
||||||
|
|
||||||
let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
|
let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
|
||||||
let dom_start_timer = Instant::now();
|
let dom_start_timer = Instant::now();
|
||||||
@@ -69,11 +74,11 @@ impl SyncbackCommand {
|
|||||||
dom_start_timer.elapsed().as_secs_f32()
|
dom_start_timer.elapsed().as_secs_f32()
|
||||||
);
|
);
|
||||||
|
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
vfs.set_watch_enabled(false);
|
vfs.set_watch_enabled(false);
|
||||||
|
|
||||||
let project_start_timer = Instant::now();
|
let project_start_timer = Instant::now();
|
||||||
let session_old = ServeSession::new(vfs, path_old.clone())?;
|
let session_old = ServeSession::new(vfs, path_old.clone(), None)?;
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Finished opening project in {:0.02}s",
|
"Finished opening project in {:0.02}s",
|
||||||
project_start_timer.elapsed().as_secs_f32()
|
project_start_timer.elapsed().as_secs_f32()
|
||||||
@@ -104,6 +109,7 @@ impl SyncbackCommand {
|
|||||||
&mut dom_old,
|
&mut dom_old,
|
||||||
dom_new,
|
dom_new,
|
||||||
session_old.root_project(),
|
session_old.root_project(),
|
||||||
|
self.dangerously_force_json,
|
||||||
)?;
|
)?;
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Syncback finished in {:.02}s!",
|
"Syncback finished in {:.02}s!",
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ pub struct UploadCommand {
|
|||||||
|
|
||||||
impl UploadCommand {
|
impl UploadCommand {
|
||||||
pub fn run(self) -> Result<(), anyhow::Error> {
|
pub fn run(self) -> Result<(), anyhow::Error> {
|
||||||
let project_path = resolve_path(&self.project)?;
|
let project_path = resolve_path(&self.project);
|
||||||
|
|
||||||
let vfs = Vfs::new_default()?;
|
let vfs = Vfs::new_default();
|
||||||
|
|
||||||
let session = ServeSession::new(vfs, project_path)?;
|
let session = ServeSession::new(vfs, project_path, None)?;
|
||||||
|
|
||||||
let tree = session.tree();
|
let tree = session.tree();
|
||||||
let inner_tree = tree.inner();
|
let inner_tree = tree.inner();
|
||||||
|
|||||||
380
src/git.rs
Normal file
380
src/git.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
//! Git integration for filtering files based on changes since a reference.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
|
||||||
|
/// A filter that tracks which files have been changed since a Git reference.
|
||||||
|
///
|
||||||
|
/// When active, only files that have been modified, added, or deleted according
|
||||||
|
/// to Git will be "acknowledged" and synced to Studio. This allows users to
|
||||||
|
/// work with large projects where they only want to sync their local changes.
|
||||||
|
///
|
||||||
|
/// Once a file is acknowledged (either initially or during the session), it
|
||||||
|
/// stays acknowledged for the entire session. This prevents files from being
|
||||||
|
/// deleted in Studio if their content is reverted to match the git reference.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GitFilter {
|
||||||
|
/// The Git repository root directory.
|
||||||
|
repo_root: PathBuf,
|
||||||
|
|
||||||
|
/// The Git reference to compare against (e.g., "HEAD", "main", a commit hash).
|
||||||
|
base_ref: String,
|
||||||
|
|
||||||
|
/// Cache of paths that are currently different from the base ref according to git.
|
||||||
|
/// This is refreshed on every VFS event.
|
||||||
|
git_changed_paths: RwLock<HashSet<PathBuf>>,
|
||||||
|
|
||||||
|
/// Paths that have been acknowledged at any point during this session.
|
||||||
|
/// Once a path is added here, it stays acknowledged forever (for this session).
|
||||||
|
/// This prevents files from being deleted if their content is reverted.
|
||||||
|
session_acknowledged_paths: RwLock<HashSet<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitFilter {
|
||||||
|
/// Creates a new GitFilter for the given repository root and base reference.
|
||||||
|
///
|
||||||
|
/// The `repo_root` should be the root of the Git repository (where .git is located).
|
||||||
|
/// The `base_ref` is the Git reference to compare against (e.g., "HEAD", "main").
|
||||||
|
/// The `project_path` is the path to the project being served - it will always be
|
||||||
|
/// acknowledged regardless of git status to ensure the project structure exists.
|
||||||
|
pub fn new(repo_root: PathBuf, base_ref: String, project_path: &Path) -> anyhow::Result<Self> {
|
||||||
|
let filter = Self {
|
||||||
|
repo_root,
|
||||||
|
base_ref,
|
||||||
|
git_changed_paths: RwLock::new(HashSet::new()),
|
||||||
|
session_acknowledged_paths: RwLock::new(HashSet::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always acknowledge the project path and its directory so the project
|
||||||
|
// structure exists even when there are no git changes
|
||||||
|
filter.acknowledge_project_path(project_path);
|
||||||
|
|
||||||
|
// Initial refresh to populate the cache with git changes
|
||||||
|
filter.refresh()?;
|
||||||
|
|
||||||
|
Ok(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acknowledges the project path and its containing directory.
|
||||||
|
/// This ensures the project structure always exists regardless of git status.
|
||||||
|
fn acknowledge_project_path(&self, project_path: &Path) {
|
||||||
|
let mut session = self.session_acknowledged_paths.write().unwrap();
|
||||||
|
|
||||||
|
// Acknowledge the project path itself (might be a directory or .project.json file)
|
||||||
|
let canonical = project_path.canonicalize().unwrap_or_else(|_| project_path.to_path_buf());
|
||||||
|
session.insert(canonical.clone());
|
||||||
|
|
||||||
|
// Acknowledge all ancestor directories
|
||||||
|
let mut current = canonical.parent();
|
||||||
|
while let Some(parent) = current {
|
||||||
|
session.insert(parent.to_path_buf());
|
||||||
|
current = parent.parent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a directory, also acknowledge default.project.json inside it
|
||||||
|
if project_path.is_dir() {
|
||||||
|
for name in &["default.project.json", "default.project.jsonc"] {
|
||||||
|
let project_file = project_path.join(name);
|
||||||
|
if let Ok(canonical_file) = project_file.canonicalize() {
|
||||||
|
session.insert(canonical_file);
|
||||||
|
} else {
|
||||||
|
session.insert(project_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a .project.json file, also acknowledge its parent directory
|
||||||
|
if let Some(parent) = project_path.parent() {
|
||||||
|
let parent_canonical = parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf());
|
||||||
|
session.insert(parent_canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"GitFilter: acknowledged project path {} ({} paths total)",
|
||||||
|
project_path.display(),
|
||||||
|
session.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the Git repository root for the given path.
|
||||||
|
pub fn find_repo_root(path: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["rev-parse", "--show-toplevel"])
|
||||||
|
.current_dir(path)
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute git rev-parse")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
bail!("Failed to find Git repository root: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(PathBuf::from(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the cache of acknowledged paths by querying Git.
|
||||||
|
///
|
||||||
|
/// This should be called when files change to ensure newly modified files
|
||||||
|
/// are properly acknowledged. Once a path is acknowledged, it stays
|
||||||
|
/// acknowledged for the entire session (even if the file is reverted).
|
||||||
|
pub fn refresh(&self) -> anyhow::Result<()> {
|
||||||
|
let mut git_changed = HashSet::new();
|
||||||
|
|
||||||
|
// Get files changed since the base ref (modified, added, deleted)
|
||||||
|
let diff_output = Command::new("git")
|
||||||
|
.args(["diff", "--name-only", &self.base_ref])
|
||||||
|
.current_dir(&self.repo_root)
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute git diff")?;
|
||||||
|
|
||||||
|
if !diff_output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&diff_output.stderr);
|
||||||
|
bail!("git diff failed: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff_files = String::from_utf8_lossy(&diff_output.stdout);
|
||||||
|
let diff_count = diff_files.lines().filter(|l| !l.is_empty()).count();
|
||||||
|
if diff_count > 0 {
|
||||||
|
log::debug!("git diff found {} changed files", diff_count);
|
||||||
|
}
|
||||||
|
for line in diff_files.lines() {
|
||||||
|
if !line.is_empty() {
|
||||||
|
let path = self.repo_root.join(line);
|
||||||
|
log::trace!("git diff: acknowledging {}", path.display());
|
||||||
|
self.acknowledge_path(&path, &mut git_changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get untracked files (new files not yet committed)
|
||||||
|
let untracked_output = Command::new("git")
|
||||||
|
.args(["ls-files", "--others", "--exclude-standard"])
|
||||||
|
.current_dir(&self.repo_root)
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute git ls-files")?;
|
||||||
|
|
||||||
|
if !untracked_output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&untracked_output.stderr);
|
||||||
|
bail!("git ls-files failed: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let untracked_files = String::from_utf8_lossy(&untracked_output.stdout);
|
||||||
|
for line in untracked_files.lines() {
|
||||||
|
if !line.is_empty() {
|
||||||
|
let path = self.repo_root.join(line);
|
||||||
|
self.acknowledge_path(&path, &mut git_changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get staged files (files added to index but not yet committed)
|
||||||
|
let staged_output = Command::new("git")
|
||||||
|
.args(["diff", "--name-only", "--cached", &self.base_ref])
|
||||||
|
.current_dir(&self.repo_root)
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute git diff --cached")?;
|
||||||
|
|
||||||
|
if staged_output.status.success() {
|
||||||
|
let staged_files = String::from_utf8_lossy(&staged_output.stdout);
|
||||||
|
for line in staged_files.lines() {
|
||||||
|
if !line.is_empty() {
|
||||||
|
let path = self.repo_root.join(line);
|
||||||
|
self.acknowledge_path(&path, &mut git_changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the git changed paths cache
|
||||||
|
{
|
||||||
|
let mut cache = self.git_changed_paths.write().unwrap();
|
||||||
|
*cache = git_changed.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge newly changed paths into session acknowledged paths
|
||||||
|
// Once acknowledged, a path stays acknowledged for the entire session
|
||||||
|
{
|
||||||
|
let mut session = self.session_acknowledged_paths.write().unwrap();
|
||||||
|
for path in git_changed {
|
||||||
|
session.insert(path);
|
||||||
|
}
|
||||||
|
log::debug!(
|
||||||
|
"GitFilter refreshed: {} paths acknowledged in session",
|
||||||
|
session.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acknowledges a path and all its ancestors, plus associated meta files.
|
||||||
|
fn acknowledge_path(&self, path: &Path, acknowledged: &mut HashSet<PathBuf>) {
|
||||||
|
// Canonicalize the path if possible, otherwise use as-is
|
||||||
|
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
|
||||||
|
// Add the path itself
|
||||||
|
acknowledged.insert(path.clone());
|
||||||
|
|
||||||
|
// Add all ancestor directories
|
||||||
|
let mut current = path.parent();
|
||||||
|
while let Some(parent) = current {
|
||||||
|
acknowledged.insert(parent.to_path_buf());
|
||||||
|
current = parent.parent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add associated meta files
|
||||||
|
self.acknowledge_meta_files(&path, acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acknowledges associated meta files for a given path.
|
||||||
|
fn acknowledge_meta_files(&self, path: &Path, acknowledged: &mut HashSet<PathBuf>) {
|
||||||
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
// For a file like "foo.lua", also acknowledge "foo.meta.json"
|
||||||
|
// Strip known extensions to get the base name
|
||||||
|
let base_name = strip_lua_extension(file_name);
|
||||||
|
|
||||||
|
let meta_path = parent.join(format!("{}.meta.json", base_name));
|
||||||
|
if let Ok(canonical) = meta_path.canonicalize() {
|
||||||
|
acknowledged.insert(canonical);
|
||||||
|
} else {
|
||||||
|
acknowledged.insert(meta_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For init files, also acknowledge "init.meta.json" in the same directory
|
||||||
|
if file_name.starts_with("init.") {
|
||||||
|
let init_meta = parent.join("init.meta.json");
|
||||||
|
if let Ok(canonical) = init_meta.canonicalize() {
|
||||||
|
acknowledged.insert(canonical);
|
||||||
|
} else {
|
||||||
|
acknowledged.insert(init_meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a path is acknowledged (should be synced).
|
||||||
|
///
|
||||||
|
/// Returns `true` if the path or any of its descendants have been changed
|
||||||
|
/// at any point during this session. Once a file is acknowledged, it stays
|
||||||
|
/// acknowledged even if its content is reverted to match the git reference.
|
||||||
|
pub fn is_acknowledged(&self, path: &Path) -> bool {
|
||||||
|
let session = self.session_acknowledged_paths.read().unwrap();
|
||||||
|
|
||||||
|
// Try to canonicalize the path
|
||||||
|
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
|
||||||
|
// Check if this exact path is acknowledged
|
||||||
|
if session.contains(&canonical) {
|
||||||
|
log::trace!("Path {} is directly acknowledged", path.display());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check without canonicalization in case of path differences
|
||||||
|
if session.contains(path) {
|
||||||
|
log::trace!("Path {} is acknowledged (non-canonical)", path.display());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For directories, check if any descendant is acknowledged
|
||||||
|
// This is done by checking if any acknowledged path starts with this path
|
||||||
|
for acknowledged in session.iter() {
|
||||||
|
if acknowledged.starts_with(&canonical) {
|
||||||
|
log::trace!(
|
||||||
|
"Path {} has acknowledged descendant {}",
|
||||||
|
path.display(),
|
||||||
|
acknowledged.display()
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check non-canonical
|
||||||
|
if acknowledged.starts_with(path) {
|
||||||
|
log::trace!(
|
||||||
|
"Path {} has acknowledged descendant {} (non-canonical)",
|
||||||
|
path.display(),
|
||||||
|
acknowledged.display()
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::trace!(
|
||||||
|
"Path {} is NOT acknowledged (canonical: {})",
|
||||||
|
path.display(),
|
||||||
|
canonical.display()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the base reference being compared against.
|
||||||
|
pub fn base_ref(&self) -> &str {
|
||||||
|
&self.base_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the repository root path.
|
||||||
|
pub fn repo_root(&self) -> &Path {
|
||||||
|
&self.repo_root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly acknowledges a path and all its ancestors.
|
||||||
|
/// This is useful for ensuring certain paths are always synced regardless of git status.
|
||||||
|
pub fn force_acknowledge(&self, path: &Path) {
|
||||||
|
let mut acknowledged = HashSet::new();
|
||||||
|
self.acknowledge_path(path, &mut acknowledged);
|
||||||
|
|
||||||
|
let mut session = self.session_acknowledged_paths.write().unwrap();
|
||||||
|
for p in acknowledged {
|
||||||
|
session.insert(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips Lua-related extensions from a file name to get the base name.
|
||||||
|
fn strip_lua_extension(file_name: &str) -> &str {
|
||||||
|
const EXTENSIONS: &[&str] = &[
|
||||||
|
".server.luau",
|
||||||
|
".server.lua",
|
||||||
|
".client.luau",
|
||||||
|
".client.lua",
|
||||||
|
".luau",
|
||||||
|
".lua",
|
||||||
|
];
|
||||||
|
|
||||||
|
for ext in EXTENSIONS {
|
||||||
|
if let Some(base) = file_name.strip_suffix(ext) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no Lua extension, try to strip the regular extension
|
||||||
|
file_name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(base, _)| base)
|
||||||
|
.unwrap_or(file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper around GitFilter that can be shared across threads.
|
||||||
|
pub type SharedGitFilter = Arc<GitFilter>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_lua_extension() {
|
||||||
|
assert_eq!(strip_lua_extension("foo.server.lua"), "foo");
|
||||||
|
assert_eq!(strip_lua_extension("foo.client.luau"), "foo");
|
||||||
|
assert_eq!(strip_lua_extension("foo.lua"), "foo");
|
||||||
|
assert_eq!(strip_lua_extension("init.server.lua"), "init");
|
||||||
|
assert_eq!(strip_lua_extension("bar.txt"), "bar");
|
||||||
|
assert_eq!(strip_lua_extension("noextension"), "noextension");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/glob.rs
58
src/glob.rs
@@ -48,61 +48,3 @@ impl<'de> Deserialize<'de> for Glob {
|
|||||||
Glob::new(&glob).map_err(D::Error::custom)
|
Glob::new(&glob).map_err(D::Error::custom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A glob with optional gitignore-style negation. A leading `!` marks the
|
|
||||||
/// pattern as a negation (re-includes paths that an earlier rule excluded).
|
|
||||||
/// To match a literal `!` at the start of a pattern, escape it with `\!`.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct IgnorableGlob {
|
|
||||||
glob: Glob,
|
|
||||||
negated: bool,
|
|
||||||
raw: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IgnorableGlob {
|
|
||||||
pub fn new(pattern: &str) -> Result<Self, Error> {
|
|
||||||
let (negated, body) = if let Some(rest) = pattern.strip_prefix('!') {
|
|
||||||
(true, rest)
|
|
||||||
} else if pattern.starts_with(r"\!") {
|
|
||||||
(false, &pattern[1..])
|
|
||||||
} else {
|
|
||||||
(false, pattern)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(IgnorableGlob {
|
|
||||||
glob: Glob::new(body)?,
|
|
||||||
negated,
|
|
||||||
raw: pattern.to_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_match<P: AsRef<Path>>(&self, path: P) -> bool {
|
|
||||||
self.glob.is_match(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_negation(&self) -> bool {
|
|
||||||
self.negated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for IgnorableGlob {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.negated == other.negated && self.glob == other.glob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for IgnorableGlob {}
|
|
||||||
|
|
||||||
impl Serialize for IgnorableGlob {
|
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
serializer.serialize_str(&self.raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for IgnorableGlob {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
let pattern = String::deserialize(deserializer)?;
|
|
||||||
|
|
||||||
IgnorableGlob::new(&pattern).map_err(D::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod tree_view;
|
|||||||
|
|
||||||
mod auth_cookie;
|
mod auth_cookie;
|
||||||
mod change_processor;
|
mod change_processor;
|
||||||
|
mod git;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod json;
|
mod json;
|
||||||
mod lua_ast;
|
mod lua_ast;
|
||||||
@@ -28,6 +29,7 @@ mod web;
|
|||||||
|
|
||||||
// TODO: Work out what we should expose publicly
|
// TODO: Work out what we should expose publicly
|
||||||
|
|
||||||
|
pub use git::{GitFilter, SharedGitFilter};
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use rojo_ref::*;
|
pub use rojo_ref::*;
|
||||||
pub use session_id::SessionId;
|
pub use session_id::SessionId;
|
||||||
|
|||||||
100
src/project.rs
100
src/project.rs
@@ -12,8 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule,
|
glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule, syncback::SyncbackRules,
|
||||||
syncback::SyncbackRules,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Represents 'default' project names that act as `init` files
|
/// Represents 'default' project names that act as `init` files
|
||||||
@@ -106,15 +105,6 @@ pub struct Project {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub serve_address: Option<IpAddr>,
|
pub serve_address: Option<IpAddr>,
|
||||||
|
|
||||||
/// Additional `Host`/`Origin` header values that `rojo serve` will accept
|
|
||||||
/// beyond `localhost` and the bind address, such as a hostname like
|
|
||||||
/// `mypc.lan` used to reach a network-exposed server by name. Listing any
|
|
||||||
/// host also turns on `Host`/`Origin` validation for binds where it is
|
|
||||||
/// otherwise off (such as `0.0.0.0`). The `--allowed-hosts` CLI option
|
|
||||||
/// overrides this field when provided.
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub serve_allowed_hosts: Vec<String>,
|
|
||||||
|
|
||||||
/// Determines if Rojo should emit scripts with the appropriate `RunContext`
|
/// Determines if Rojo should emit scripts with the appropriate `RunContext`
|
||||||
/// for `*.client.lua` and `*.server.lua` files in the project instead of
|
/// for `*.client.lua` and `*.server.lua` files in the project instead of
|
||||||
/// using `Script` and `LocalScript` Instances.
|
/// using `Script` and `LocalScript` Instances.
|
||||||
@@ -124,7 +114,7 @@ pub struct Project {
|
|||||||
/// A list of globs, relative to the folder the project file is in, that
|
/// A list of globs, relative to the folder the project file is in, that
|
||||||
/// match files that should be excluded if Rojo encounters them.
|
/// match files that should be excluded if Rojo encounters them.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub glob_ignore_paths: Vec<IgnorableGlob>,
|
pub glob_ignore_paths: Vec<Glob>,
|
||||||
|
|
||||||
/// A list of rules for syncback with this project file.
|
/// A list of rules for syncback with this project file.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -603,90 +593,4 @@ mod test {
|
|||||||
assert!(project.sync_rules[0].include.is_match("data.data.json"));
|
assert!(project.sync_rules[0].include.is_match("data.data.json"));
|
||||||
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
|
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_with_serve_allowed_hosts() {
|
|
||||||
let project_json = r#"{
|
|
||||||
"name": "TestProject",
|
|
||||||
"tree": { "$path": "src" },
|
|
||||||
"serveAllowedHosts": ["mypc.lan", "192.168.1.5"]
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let project = Project::load_from_slice(
|
|
||||||
project_json.as_bytes(),
|
|
||||||
PathBuf::from("/test/default.project.json"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("Failed to parse project with serveAllowedHosts");
|
|
||||||
|
|
||||||
assert_eq!(project.serve_allowed_hosts, vec!["mypc.lan", "192.168.1.5"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_without_serve_allowed_hosts_defaults_to_empty() {
|
|
||||||
let project_json = r#"{
|
|
||||||
"name": "TestProject",
|
|
||||||
"tree": { "$path": "src" }
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let project = Project::load_from_slice(
|
|
||||||
project_json.as_bytes(),
|
|
||||||
PathBuf::from("/test/default.project.json"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("Failed to parse project");
|
|
||||||
|
|
||||||
assert!(project.serve_allowed_hosts.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn glob_ignore_paths_negation() {
|
|
||||||
let project_json = r#"{
|
|
||||||
"name": "TestProject",
|
|
||||||
"tree": { "$path": "src" },
|
|
||||||
"globIgnorePaths": [
|
|
||||||
"**/*.spec.lua",
|
|
||||||
"!keep.spec.lua",
|
|
||||||
"\\!literal.lua"
|
|
||||||
]
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let project = Project::load_from_slice(
|
|
||||||
project_json.as_bytes(),
|
|
||||||
PathBuf::from("/test/default.project.json"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("project should parse");
|
|
||||||
|
|
||||||
let paths = &project.glob_ignore_paths;
|
|
||||||
assert_eq!(paths.len(), 3);
|
|
||||||
|
|
||||||
assert!(!paths[0].is_negation());
|
|
||||||
assert!(paths[0].is_match("foo.spec.lua"));
|
|
||||||
|
|
||||||
assert!(paths[1].is_negation());
|
|
||||||
assert!(paths[1].is_match("keep.spec.lua"));
|
|
||||||
|
|
||||||
// `\!literal.lua` should match a file literally named `!literal.lua`,
|
|
||||||
// not be parsed as a negation.
|
|
||||||
assert!(!paths[2].is_negation());
|
|
||||||
assert!(paths[2].is_match("!literal.lua"));
|
|
||||||
|
|
||||||
let rules: Vec<_> = paths
|
|
||||||
.iter()
|
|
||||||
.map(|g| crate::snapshot::PathIgnoreRule {
|
|
||||||
base_path: PathBuf::from("/test"),
|
|
||||||
glob: g.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert!(crate::snapshot::is_path_ignored(
|
|
||||||
&rules,
|
|
||||||
"/test/foo.spec.lua"
|
|
||||||
));
|
|
||||||
assert!(!crate::snapshot::is_path_ignored(
|
|
||||||
&rules,
|
|
||||||
"/test/keep.spec.lua"
|
|
||||||
));
|
|
||||||
assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use thiserror::Error;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
change_processor::ChangeProcessor,
|
change_processor::ChangeProcessor,
|
||||||
|
git::SharedGitFilter,
|
||||||
message_queue::MessageQueue,
|
message_queue::MessageQueue,
|
||||||
project::{Project, ProjectError},
|
project::{Project, ProjectError},
|
||||||
session_id::SessionId,
|
session_id::SessionId,
|
||||||
@@ -94,7 +95,14 @@ impl ServeSession {
|
|||||||
/// The project file is expected to be loaded out-of-band since it's
|
/// The project file is expected to be loaded out-of-band since it's
|
||||||
/// currently loaded from the filesystem directly instead of through the
|
/// currently loaded from the filesystem directly instead of through the
|
||||||
/// in-memory filesystem layer.
|
/// in-memory filesystem layer.
|
||||||
pub fn new<P: AsRef<Path>>(vfs: Vfs, start_path: P) -> Result<Self, ServeSessionError> {
|
///
|
||||||
|
/// If `git_filter` is provided, only files that have changed since the
|
||||||
|
/// specified Git reference will be synced.
|
||||||
|
pub fn new<P: AsRef<Path>>(
|
||||||
|
vfs: Vfs,
|
||||||
|
start_path: P,
|
||||||
|
git_filter: Option<SharedGitFilter>,
|
||||||
|
) -> Result<Self, ServeSessionError> {
|
||||||
let start_path = start_path.as_ref();
|
let start_path = start_path.as_ref();
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
|
|
||||||
@@ -102,12 +110,28 @@ impl ServeSession {
|
|||||||
|
|
||||||
let root_project = Project::load_initial_project(&vfs, start_path)?;
|
let root_project = Project::load_initial_project(&vfs, start_path)?;
|
||||||
|
|
||||||
|
// If git filter is active, ensure the project file location is acknowledged
|
||||||
|
// This is necessary so the project structure exists even with no git changes
|
||||||
|
if let Some(ref filter) = git_filter {
|
||||||
|
filter.force_acknowledge(start_path);
|
||||||
|
filter.force_acknowledge(&root_project.file_location);
|
||||||
|
filter.force_acknowledge(root_project.folder_location());
|
||||||
|
log::debug!(
|
||||||
|
"Force acknowledged project at {}",
|
||||||
|
root_project.file_location.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut tree = RojoTree::new(InstanceSnapshot::new());
|
let mut tree = RojoTree::new(InstanceSnapshot::new());
|
||||||
|
|
||||||
let root_id = tree.get_root_id();
|
let root_id = tree.get_root_id();
|
||||||
|
|
||||||
let instance_context =
|
let instance_context = match &git_filter {
|
||||||
InstanceContext::with_emit_legacy_scripts(root_project.emit_legacy_scripts);
|
Some(filter) => {
|
||||||
|
InstanceContext::with_git_filter(root_project.emit_legacy_scripts, Arc::clone(filter))
|
||||||
|
}
|
||||||
|
None => InstanceContext::with_emit_legacy_scripts(root_project.emit_legacy_scripts),
|
||||||
|
};
|
||||||
|
|
||||||
log::trace!("Generating snapshot of instances from VFS");
|
log::trace!("Generating snapshot of instances from VFS");
|
||||||
let snapshot = snapshot_from_vfs(&instance_context, &vfs, start_path)?;
|
let snapshot = snapshot_from_vfs(&instance_context, &vfs, start_path)?;
|
||||||
@@ -133,6 +157,7 @@ impl ServeSession {
|
|||||||
Arc::clone(&vfs),
|
Arc::clone(&vfs),
|
||||||
Arc::clone(&message_queue),
|
Arc::clone(&message_queue),
|
||||||
tree_mutation_receiver,
|
tree_mutation_receiver,
|
||||||
|
git_filter,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -207,10 +232,6 @@ impl ServeSession {
|
|||||||
self.root_project.serve_address
|
self.root_project.serve_address
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn serve_allowed_hosts(&self) -> &[String] {
|
|
||||||
&self.root_project.serve_allowed_hosts
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn root_dir(&self) -> &Path {
|
pub fn root_dir(&self) -> &Path {
|
||||||
self.root_project.folder_location()
|
self.root_project.folder_location()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use anyhow::Context;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
glob::{Glob, IgnorableGlob},
|
git::SharedGitFilter,
|
||||||
|
glob::Glob,
|
||||||
path_serializer,
|
path_serializer,
|
||||||
project::ProjectNode,
|
project::ProjectNode,
|
||||||
snapshot_middleware::{emit_legacy_scripts_default, Middleware},
|
snapshot_middleware::{emit_legacy_scripts_default, Middleware},
|
||||||
@@ -70,6 +71,12 @@ pub struct InstanceMetadata {
|
|||||||
/// A schema provided via a JSON file, if one exists. Will be `None` for
|
/// A schema provided via a JSON file, if one exists. Will be `None` for
|
||||||
/// all non-JSON middleware.
|
/// all non-JSON middleware.
|
||||||
pub schema: Option<String>,
|
pub schema: Option<String>,
|
||||||
|
|
||||||
|
/// A custom name specified via meta.json or model.json files. If present,
|
||||||
|
/// this name will be used for the instance while the filesystem name will
|
||||||
|
/// be slugified to remove illegal characters.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub specified_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstanceMetadata {
|
impl InstanceMetadata {
|
||||||
@@ -82,6 +89,7 @@ impl InstanceMetadata {
|
|||||||
specified_id: None,
|
specified_id: None,
|
||||||
middleware: None,
|
middleware: None,
|
||||||
schema: None,
|
schema: None,
|
||||||
|
specified_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +138,13 @@ impl InstanceMetadata {
|
|||||||
pub fn schema(self, schema: Option<String>) -> Self {
|
pub fn schema(self, schema: Option<String>) -> Self {
|
||||||
Self { schema, ..self }
|
Self { schema, ..self }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn specified_name(self, specified_name: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
specified_name,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InstanceMetadata {
|
impl Default for InstanceMetadata {
|
||||||
@@ -138,13 +153,27 @@ impl Default for InstanceMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InstanceContext {
|
pub struct InstanceContext {
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
|
pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
|
||||||
pub emit_legacy_scripts: bool,
|
pub emit_legacy_scripts: bool,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub sync_rules: Vec<SyncRule>,
|
pub sync_rules: Vec<SyncRule>,
|
||||||
|
/// Optional Git filter for --git-since mode. When set, only files that have
|
||||||
|
/// changed since the specified Git reference will be synced.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub git_filter: Option<SharedGitFilter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for InstanceContext {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
// Note: git_filter is intentionally excluded from comparison
|
||||||
|
// since it's runtime state, not configuration
|
||||||
|
self.path_ignore_rules == other.path_ignore_rules
|
||||||
|
&& self.emit_legacy_scripts == other.emit_legacy_scripts
|
||||||
|
&& self.sync_rules == other.sync_rules
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstanceContext {
|
impl InstanceContext {
|
||||||
@@ -153,6 +182,7 @@ impl InstanceContext {
|
|||||||
path_ignore_rules: Arc::new(Vec::new()),
|
path_ignore_rules: Arc::new(Vec::new()),
|
||||||
emit_legacy_scripts: emit_legacy_scripts_default().unwrap(),
|
emit_legacy_scripts: emit_legacy_scripts_default().unwrap(),
|
||||||
sync_rules: Vec::new(),
|
sync_rules: Vec::new(),
|
||||||
|
git_filter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +195,36 @@ impl InstanceContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a new InstanceContext with a Git filter for --git-since mode.
|
||||||
|
pub fn with_git_filter(
|
||||||
|
emit_legacy_scripts: Option<bool>,
|
||||||
|
git_filter: SharedGitFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
git_filter: Some(git_filter),
|
||||||
|
..Self::with_emit_legacy_scripts(emit_legacy_scripts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the Git filter for this context.
|
||||||
|
pub fn set_git_filter(&mut self, git_filter: Option<SharedGitFilter>) {
|
||||||
|
self.git_filter = git_filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the given path should be acknowledged (synced).
|
||||||
|
/// If no git filter is set, all paths are acknowledged.
|
||||||
|
pub fn is_path_acknowledged(&self, path: &Path) -> bool {
|
||||||
|
match &self.git_filter {
|
||||||
|
Some(filter) => filter.is_acknowledged(path),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a git filter is active.
|
||||||
|
pub fn has_git_filter(&self) -> bool {
|
||||||
|
self.git_filter.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Extend the list of ignore rules in the context with the given new rules.
|
/// Extend the list of ignore rules in the context with the given new rules.
|
||||||
pub fn add_path_ignore_rules<I>(&mut self, new_rules: I)
|
pub fn add_path_ignore_rules<I>(&mut self, new_rules: I)
|
||||||
where
|
where
|
||||||
@@ -222,37 +282,18 @@ pub struct PathIgnoreRule {
|
|||||||
pub base_path: PathBuf,
|
pub base_path: PathBuf,
|
||||||
|
|
||||||
/// The actual glob that can be matched against the input path.
|
/// The actual glob that can be matched against the input path.
|
||||||
pub glob: IgnorableGlob,
|
pub glob: Glob,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PathIgnoreRule {
|
impl PathIgnoreRule {
|
||||||
pub fn matches<P: AsRef<Path>>(&self, path: P) -> bool {
|
pub fn passes<P: AsRef<Path>>(&self, path: P) -> bool {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
|
|
||||||
match path.strip_prefix(&self.base_path) {
|
match path.strip_prefix(&self.base_path) {
|
||||||
Ok(suffix) => self.glob.is_match(suffix),
|
Ok(suffix) => !self.glob.is_match(suffix),
|
||||||
Err(_) => false,
|
Err(_) => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_negation(&self) -> bool {
|
|
||||||
self.glob.is_negation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using
|
|
||||||
/// gitignore-style "last match wins" semantics: a path is ignored if the last
|
|
||||||
/// rule whose pattern matches it is non-negated. Paths matched by no rule are
|
|
||||||
/// not ignored.
|
|
||||||
pub fn is_path_ignored<P: AsRef<Path>>(rules: &[PathIgnoreRule], path: P) -> bool {
|
|
||||||
let path = path.as_ref();
|
|
||||||
let mut ignored = false;
|
|
||||||
for rule in rules {
|
|
||||||
if rule.matches(path) {
|
|
||||||
ignored = !rule.is_negation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ignored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents where a particular Instance or InstanceSnapshot came from.
|
/// Represents where a particular Instance or InstanceSnapshot came from.
|
||||||
|
|||||||
@@ -109,8 +109,14 @@ pub fn syncback_csv<'sync>(
|
|||||||
|
|
||||||
if !meta.is_empty() {
|
if !meta.is_empty() {
|
||||||
let parent = snapshot.path.parent_err()?;
|
let parent = snapshot.path.parent_err()?;
|
||||||
|
let file_name = snapshot
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let meta_stem = file_name.strip_suffix(".csv").unwrap_or(file_name);
|
||||||
fs_snapshot.add_file(
|
fs_snapshot.add_file(
|
||||||
parent.join(format!("{}.meta.json", new_inst.name)),
|
parent.join(format!("{meta_stem}.meta.json")),
|
||||||
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
|
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -195,7 +201,7 @@ struct LocalizationEntry<'a> {
|
|||||||
/// https://github.com/BurntSushi/rust-csv/issues/151
|
/// https://github.com/BurntSushi/rust-csv/issues/151
|
||||||
///
|
///
|
||||||
/// This function operates in one step in order to minimize data-copying.
|
/// This function operates in one step in order to minimize data-copying.
|
||||||
fn convert_localization_csv(contents: &[u8]) -> anyhow::Result<String> {
|
fn convert_localization_csv(contents: &[u8]) -> Result<String, csv::Error> {
|
||||||
let mut reader = csv::Reader::from_reader(contents);
|
let mut reader = csv::Reader::from_reader(contents);
|
||||||
|
|
||||||
let headers = reader.headers()?.clone();
|
let headers = reader.headers()?.clone();
|
||||||
@@ -237,7 +243,7 @@ fn convert_localization_csv(contents: &[u8]) -> anyhow::Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let encoded =
|
let encoded =
|
||||||
serde_json::to_string(&entries).context("Could not encode JSON for localization table")?;
|
serde_json::to_string(&entries).expect("Could not encode JSON for localization table");
|
||||||
|
|
||||||
Ok(encoded)
|
Ok(encoded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use anyhow::Context;
|
|||||||
use memofs::{DirEntry, Vfs};
|
use memofs::{DirEntry, Vfs};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
snapshot::{
|
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
|
||||||
is_path_ignored, InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource,
|
syncback::{
|
||||||
|
extension_for_middleware, hash_instance, FsSnapshot, SyncbackReturn,
|
||||||
|
SyncbackSnapshot,
|
||||||
},
|
},
|
||||||
syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{meta_file::DirectoryMetadata, snapshot_from_vfs};
|
use super::{meta_file::DirectoryMetadata, snapshot_from_vfs, Middleware};
|
||||||
|
|
||||||
const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep";
|
const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep";
|
||||||
|
|
||||||
@@ -43,8 +44,12 @@ pub fn snapshot_dir_no_meta(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> anyhow::Result<Option<InstanceSnapshot>> {
|
) -> anyhow::Result<Option<InstanceSnapshot>> {
|
||||||
let passes_filter_rules =
|
let passes_filter_rules = |child: &DirEntry| {
|
||||||
|child: &DirEntry| !is_path_ignored(&context.path_ignore_rules, child.path());
|
context
|
||||||
|
.path_ignore_rules
|
||||||
|
.iter()
|
||||||
|
.all(|rule| rule.passes(child.path()))
|
||||||
|
};
|
||||||
|
|
||||||
let mut snapshot_children = Vec::new();
|
let mut snapshot_children = Vec::new();
|
||||||
|
|
||||||
@@ -72,8 +77,6 @@ pub fn snapshot_dir_no_meta(
|
|||||||
normalized_path.join("init.server.luau"),
|
normalized_path.join("init.server.luau"),
|
||||||
normalized_path.join("init.client.lua"),
|
normalized_path.join("init.client.lua"),
|
||||||
normalized_path.join("init.client.luau"),
|
normalized_path.join("init.client.luau"),
|
||||||
normalized_path.join("init.plugin.lua"),
|
|
||||||
normalized_path.join("init.plugin.luau"),
|
|
||||||
normalized_path.join("init.csv"),
|
normalized_path.join("init.csv"),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -91,6 +94,22 @@ pub fn snapshot_dir_no_meta(
|
|||||||
Ok(Some(snapshot))
|
Ok(Some(snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Splits a filesystem name into (stem, extension) based on middleware type.
|
||||||
|
/// For directory middleware, the extension is empty. For file middleware,
|
||||||
|
/// the extension comes from `extension_for_middleware`.
|
||||||
|
fn split_name_and_ext(name: &str, middleware: Middleware) -> (&str, &str) {
|
||||||
|
if middleware.is_dir() {
|
||||||
|
(name, "")
|
||||||
|
} else {
|
||||||
|
let ext = extension_for_middleware(middleware);
|
||||||
|
if let Some(stem) = name.strip_suffix(&format!(".{ext}")) {
|
||||||
|
(stem, ext)
|
||||||
|
} else {
|
||||||
|
(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn syncback_dir<'sync>(
|
pub fn syncback_dir<'sync>(
|
||||||
snapshot: &SyncbackSnapshot<'sync>,
|
snapshot: &SyncbackSnapshot<'sync>,
|
||||||
) -> anyhow::Result<SyncbackReturn<'sync>> {
|
) -> anyhow::Result<SyncbackReturn<'sync>> {
|
||||||
@@ -134,65 +153,128 @@ pub fn syncback_dir_no_meta<'sync>(
|
|||||||
let mut children = Vec::new();
|
let mut children = Vec::new();
|
||||||
let mut removed_children = Vec::new();
|
let mut removed_children = Vec::new();
|
||||||
|
|
||||||
// We have to enforce unique child names for the file system.
|
// Build the old child map early so it can be used for deduplication below.
|
||||||
let mut child_names = HashSet::with_capacity(new_inst.children().len());
|
let mut old_child_map = HashMap::new();
|
||||||
let mut duplicate_set = HashSet::new();
|
|
||||||
for child_ref in new_inst.children() {
|
|
||||||
let child = snapshot.get_new_instance(*child_ref).unwrap();
|
|
||||||
if !child_names.insert(child.name.to_lowercase()) {
|
|
||||||
duplicate_set.insert(child.name.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !duplicate_set.is_empty() {
|
|
||||||
if duplicate_set.len() <= 25 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Instance has children with duplicate name (case may not exactly match):\n {}",
|
|
||||||
duplicate_set.into_iter().collect::<Vec<&str>>().join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
anyhow::bail!("Instance has more than 25 children with duplicate names");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(old_inst) = snapshot.old_inst() {
|
if let Some(old_inst) = snapshot.old_inst() {
|
||||||
let mut old_child_map = HashMap::with_capacity(old_inst.children().len());
|
|
||||||
for child in old_inst.children() {
|
for child in old_inst.children() {
|
||||||
let inst = snapshot.get_old_instance(*child).unwrap();
|
let inst = snapshot.get_old_instance(*child).unwrap();
|
||||||
old_child_map.insert(inst.name(), inst);
|
old_child_map.insert(inst.name(), inst);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for new_child_ref in new_inst.children() {
|
// --- Two-pass collision resolution ---
|
||||||
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
|
//
|
||||||
if let Some(old_child) = old_child_map.remove(new_child.name.as_str()) {
|
// Pass 1: Collect each child's base filesystem name and old ref, applying
|
||||||
if old_child.metadata().relevant_paths.is_empty() {
|
// skip conditions. Track which names are used (lowercased) so we can
|
||||||
log::debug!(
|
// detect collisions.
|
||||||
"Skipping instance {} because it doesn't exist on the disk",
|
struct ChildEntry {
|
||||||
old_child.name()
|
new_ref: rbx_dom_weak::types::Ref,
|
||||||
);
|
old_ref: Option<rbx_dom_weak::types::Ref>,
|
||||||
continue;
|
base_name: String,
|
||||||
} else if matches!(
|
middleware: Middleware,
|
||||||
old_child.metadata().instigating_source,
|
skip: bool,
|
||||||
Some(InstigatingSource::ProjectNode { .. })
|
}
|
||||||
) {
|
|
||||||
log::debug!(
|
let mut entries = Vec::with_capacity(new_inst.children().len());
|
||||||
"Skipping instance {} because it originates in a project file",
|
let mut used_names: HashSet<String> = HashSet::with_capacity(new_inst.children().len());
|
||||||
old_child.name()
|
let mut collision_indices: Vec<usize> = Vec::new();
|
||||||
);
|
|
||||||
continue;
|
for new_child_ref in new_inst.children() {
|
||||||
}
|
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
|
||||||
// This child exists in both doms. Pass it on.
|
|
||||||
children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?);
|
// Determine old_ref and apply skip conditions.
|
||||||
} else {
|
let old_child = if snapshot.old_inst().is_some() {
|
||||||
// The child only exists in the the new dom
|
old_child_map.remove(new_child.name.as_str())
|
||||||
children.push(snapshot.with_joined_path(*new_child_ref, None)?);
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut skip = false;
|
||||||
|
if let Some(ref old) = old_child {
|
||||||
|
if old.metadata().relevant_paths.is_empty() {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping instance {} because it doesn't exist on the disk",
|
||||||
|
old.name()
|
||||||
|
);
|
||||||
|
skip = true;
|
||||||
|
} else if matches!(
|
||||||
|
old.metadata().instigating_source,
|
||||||
|
Some(InstigatingSource::ProjectNode { .. })
|
||||||
|
) {
|
||||||
|
log::debug!(
|
||||||
|
"Skipping instance {} because it originates in a project file",
|
||||||
|
old.name()
|
||||||
|
);
|
||||||
|
skip = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Any children that are in the old dom but not the new one are removed.
|
|
||||||
removed_children.extend(old_child_map.into_values());
|
let old_ref = old_child.as_ref().map(|o| o.id());
|
||||||
} else {
|
|
||||||
// There is no old instance. Just add every child.
|
if skip {
|
||||||
for new_child_ref in new_inst.children() {
|
entries.push(ChildEntry {
|
||||||
children.push(snapshot.with_joined_path(*new_child_ref, None)?);
|
new_ref: *new_child_ref,
|
||||||
|
old_ref,
|
||||||
|
base_name: String::new(),
|
||||||
|
middleware: Middleware::Dir,
|
||||||
|
skip: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (middleware, base_name) =
|
||||||
|
snapshot.child_middleware_and_name(*new_child_ref, old_ref)?;
|
||||||
|
|
||||||
|
let idx = entries.len();
|
||||||
|
let lower = base_name.to_lowercase();
|
||||||
|
if !used_names.insert(lower) {
|
||||||
|
// Name already claimed — needs resolution.
|
||||||
|
collision_indices.push(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(ChildEntry {
|
||||||
|
new_ref: *new_child_ref,
|
||||||
|
old_ref,
|
||||||
|
base_name,
|
||||||
|
middleware,
|
||||||
|
skip: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Resolve collisions by appending incrementing suffixes.
|
||||||
|
for idx in collision_indices {
|
||||||
|
let entry = &entries[idx];
|
||||||
|
let (stem, ext) = split_name_and_ext(&entry.base_name, entry.middleware);
|
||||||
|
let mut counter = 1u32;
|
||||||
|
loop {
|
||||||
|
let candidate = if ext.is_empty() {
|
||||||
|
format!("{stem}{counter}")
|
||||||
|
} else {
|
||||||
|
format!("{stem}{counter}.{ext}")
|
||||||
|
};
|
||||||
|
let lower = candidate.to_lowercase();
|
||||||
|
if used_names.insert(lower) {
|
||||||
|
// Safe to mutate — we only visit each collision index once.
|
||||||
|
let entry = &mut entries[idx];
|
||||||
|
entry.base_name = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create snapshots from resolved entries.
|
||||||
|
for entry in &entries {
|
||||||
|
if entry.skip {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resolved_path = snapshot.path.join(&entry.base_name);
|
||||||
|
children.push(snapshot.with_new_path(resolved_path, entry.new_ref, entry.old_ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any children that are in the old dom but not the new one are removed.
|
||||||
|
if snapshot.old_inst().is_some() {
|
||||||
|
removed_children.extend(old_child_map.into_values());
|
||||||
}
|
}
|
||||||
let mut fs_snapshot = FsSnapshot::new();
|
let mut fs_snapshot = FsSnapshot::new();
|
||||||
|
|
||||||
@@ -225,6 +307,12 @@ pub fn syncback_dir_no_meta<'sync>(
|
|||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
snapshot::{InstanceMetadata, InstanceSnapshot},
|
||||||
|
Project, RojoTree, SyncbackData, SyncbackSnapshot,
|
||||||
|
};
|
||||||
use memofs::{InMemoryFs, VfsSnapshot};
|
use memofs::{InMemoryFs, VfsSnapshot};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -261,4 +349,302 @@ mod test {
|
|||||||
|
|
||||||
insta::assert_yaml_snapshot!(instance_snapshot);
|
insta::assert_yaml_snapshot!(instance_snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_project() -> Project {
|
||||||
|
serde_json::from_str(r#"{"tree": {"$className": "DataModel"}}"#).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_vfs() -> Vfs {
|
||||||
|
let mut imfs = InMemoryFs::new();
|
||||||
|
imfs.load_snapshot("/root", VfsSnapshot::empty_dir()).unwrap();
|
||||||
|
Vfs::new(imfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two children whose Roblox names are identical when lowercased ("Alpha"
|
||||||
|
/// and "alpha") but live at different filesystem paths because of the
|
||||||
|
/// `name` property ("Beta/" and "Alpha/" respectively). The dedup check
|
||||||
|
/// must use the actual filesystem paths, not the raw Roblox names, to
|
||||||
|
/// avoid a false-positive duplicate error.
|
||||||
|
#[test]
|
||||||
|
fn syncback_no_false_duplicate_with_name_prop() {
|
||||||
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|
||||||
|
// Old child A: Roblox name "Alpha", on disk at "/root/Beta"
|
||||||
|
// (name property maps "Alpha" → "Beta" on the filesystem)
|
||||||
|
let old_child_a = InstanceSnapshot::new()
|
||||||
|
.name("Alpha")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root/Beta"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root/Beta")]),
|
||||||
|
);
|
||||||
|
// Old child B: Roblox name "alpha", on disk at "/root/Alpha"
|
||||||
|
let old_child_b = InstanceSnapshot::new()
|
||||||
|
.name("alpha")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root/Alpha"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root/Alpha")]),
|
||||||
|
);
|
||||||
|
let old_parent = InstanceSnapshot::new()
|
||||||
|
.name("Parent")
|
||||||
|
.class_name("Folder")
|
||||||
|
.children(vec![old_child_a, old_child_b])
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
||||||
|
);
|
||||||
|
let old_tree = RojoTree::new(old_parent);
|
||||||
|
|
||||||
|
// New state: same two children in Roblox.
|
||||||
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||||
|
let new_parent = new_tree.insert(
|
||||||
|
new_tree.root_ref(),
|
||||||
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
||||||
|
);
|
||||||
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Alpha"));
|
||||||
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("alpha"));
|
||||||
|
|
||||||
|
let vfs = make_vfs();
|
||||||
|
let project = make_project();
|
||||||
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
||||||
|
let snapshot = SyncbackSnapshot {
|
||||||
|
data,
|
||||||
|
old: Some(old_tree.get_root_id()),
|
||||||
|
new: new_parent,
|
||||||
|
path: PathBuf::from("/root"),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = syncback_dir_no_meta(&snapshot);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should not error when two children have the same lowercased Roblox \
|
||||||
|
name but map to distinct filesystem paths: {:?}",
|
||||||
|
result.as_ref().err(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two completely new children with the same name get resolved via
|
||||||
|
/// incrementing suffixes instead of erroring.
|
||||||
|
#[test]
|
||||||
|
fn syncback_resolves_sibling_duplicate_names() {
|
||||||
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|
||||||
|
let old_parent = InstanceSnapshot::new()
|
||||||
|
.name("Parent")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
||||||
|
);
|
||||||
|
let old_tree = RojoTree::new(old_parent);
|
||||||
|
|
||||||
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||||
|
let new_parent = new_tree.insert(
|
||||||
|
new_tree.root_ref(),
|
||||||
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
||||||
|
);
|
||||||
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
|
||||||
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
|
||||||
|
|
||||||
|
let vfs = make_vfs();
|
||||||
|
let project = make_project();
|
||||||
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
||||||
|
let snapshot = SyncbackSnapshot {
|
||||||
|
data,
|
||||||
|
old: Some(old_tree.get_root_id()),
|
||||||
|
new: new_parent,
|
||||||
|
path: PathBuf::from("/root"),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = syncback_dir_no_meta(&snapshot);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should resolve duplicate names with suffixes, not error: {:?}",
|
||||||
|
result.as_ref().err(),
|
||||||
|
);
|
||||||
|
let children = result.unwrap().children;
|
||||||
|
let mut names: Vec<String> = children
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.path.file_name().unwrap().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, vec!["Foo", "Foo1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new child named "Init" (as a ModuleScript) would naively become
|
||||||
|
/// "Init.luau", which case-insensitively matches the parent's reserved
|
||||||
|
/// "init.luau". Syncback must resolve this automatically by prefixing the
|
||||||
|
/// filesystem name with '_' (→ "_Init.luau") rather than erroring.
|
||||||
|
#[test]
|
||||||
|
fn syncback_resolves_init_name_conflict() {
|
||||||
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|
||||||
|
let old_parent = InstanceSnapshot::new()
|
||||||
|
.name("Parent")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
||||||
|
);
|
||||||
|
let old_tree = RojoTree::new(old_parent);
|
||||||
|
|
||||||
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||||
|
let new_parent = new_tree.insert(
|
||||||
|
new_tree.root_ref(),
|
||||||
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
||||||
|
);
|
||||||
|
new_tree.insert(
|
||||||
|
new_parent,
|
||||||
|
InstanceBuilder::new("ModuleScript").with_name("Init"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let vfs = make_vfs();
|
||||||
|
let project = make_project();
|
||||||
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
||||||
|
let snapshot = SyncbackSnapshot {
|
||||||
|
data,
|
||||||
|
old: Some(old_tree.get_root_id()),
|
||||||
|
new: new_parent,
|
||||||
|
path: PathBuf::from("/root"),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = syncback_dir_no_meta(&snapshot);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should resolve init-name conflict by prefixing '_', not error: {:?}",
|
||||||
|
result.as_ref().err(),
|
||||||
|
);
|
||||||
|
// The child should have been placed at "_Init.luau", not "Init.luau".
|
||||||
|
let child_file_name = result
|
||||||
|
.unwrap()
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
child_file_name.starts_with('_'),
|
||||||
|
"child filesystem name should start with '_' to avoid init collision, \
|
||||||
|
got: {child_file_name}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child whose filesystem name is stored with a slugified prefix (e.g.
|
||||||
|
/// "_Init") must NOT be blocked — only the bare "init" stem is reserved.
|
||||||
|
#[test]
|
||||||
|
fn syncback_allows_slugified_init_name() {
|
||||||
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|
||||||
|
// Existing child: on disk as "_Init" (slugified from a name with an
|
||||||
|
// illegal character), its stem is "_init" which is not reserved.
|
||||||
|
let old_child = InstanceSnapshot::new()
|
||||||
|
.name("Init")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root/_Init"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root/_Init")]),
|
||||||
|
);
|
||||||
|
let old_parent = InstanceSnapshot::new()
|
||||||
|
.name("Parent")
|
||||||
|
.class_name("Folder")
|
||||||
|
.children(vec![old_child])
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
||||||
|
);
|
||||||
|
let old_tree = RojoTree::new(old_parent);
|
||||||
|
|
||||||
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||||
|
let new_parent = new_tree.insert(
|
||||||
|
new_tree.root_ref(),
|
||||||
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
||||||
|
);
|
||||||
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Init"));
|
||||||
|
|
||||||
|
let vfs = make_vfs();
|
||||||
|
let project = make_project();
|
||||||
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
||||||
|
let snapshot = SyncbackSnapshot {
|
||||||
|
data,
|
||||||
|
old: Some(old_tree.get_root_id()),
|
||||||
|
new: new_parent,
|
||||||
|
path: PathBuf::from("/root"),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = syncback_dir_no_meta(&snapshot);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should allow a child whose filesystem name is slugified away from \
|
||||||
|
the reserved 'init' stem: {:?}",
|
||||||
|
result.as_ref().err(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two new children both named "Init" (ModuleScripts) should get
|
||||||
|
/// "_Init.luau" and "_Init1.luau" respectively.
|
||||||
|
#[test]
|
||||||
|
fn syncback_resolves_multiple_init_conflicts() {
|
||||||
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|
||||||
|
let old_parent = InstanceSnapshot::new()
|
||||||
|
.name("Parent")
|
||||||
|
.class_name("Folder")
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(PathBuf::from("/root"))
|
||||||
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
||||||
|
);
|
||||||
|
let old_tree = RojoTree::new(old_parent);
|
||||||
|
|
||||||
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||||
|
let new_parent = new_tree.insert(
|
||||||
|
new_tree.root_ref(),
|
||||||
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
||||||
|
);
|
||||||
|
new_tree.insert(
|
||||||
|
new_parent,
|
||||||
|
InstanceBuilder::new("ModuleScript").with_name("Init"),
|
||||||
|
);
|
||||||
|
new_tree.insert(
|
||||||
|
new_parent,
|
||||||
|
InstanceBuilder::new("ModuleScript").with_name("Init"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let vfs = make_vfs();
|
||||||
|
let project = make_project();
|
||||||
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
||||||
|
let snapshot = SyncbackSnapshot {
|
||||||
|
data,
|
||||||
|
old: Some(old_tree.get_root_id()),
|
||||||
|
new: new_parent,
|
||||||
|
path: PathBuf::from("/root"),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = syncback_dir_no_meta(&snapshot);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should resolve multiple init conflicts with suffixes: {:?}",
|
||||||
|
result.as_ref().err(),
|
||||||
|
);
|
||||||
|
let children = result.unwrap().children;
|
||||||
|
let mut names: Vec<String> = children
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.path.file_name().unwrap().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, vec!["_Init.luau", "_Init1.luau"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,20 +35,14 @@ pub fn snapshot_json_model(
|
|||||||
format!("File is not a valid JSON model: {}", path.display())
|
format!("File is not a valid JSON model: {}", path.display())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(top_level_name) = &instance.name {
|
// If the JSON has a name property, preserve it in metadata for syncback
|
||||||
let new_name = format!("{}.model.json", top_level_name);
|
let specified_name = instance.name.clone();
|
||||||
|
|
||||||
log::warn!(
|
// Use the name from JSON if present, otherwise fall back to filename-derived name
|
||||||
"Model at path {} had a top-level Name field. \
|
if instance.name.is_none() {
|
||||||
This field has been ignored since Rojo 6.0.\n\
|
instance.name = Some(name.to_owned());
|
||||||
Consider removing this field and renaming the file to {}.",
|
|
||||||
new_name,
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instance.name = Some(name.to_owned());
|
|
||||||
|
|
||||||
let id = instance.id.take().map(RojoRef::new);
|
let id = instance.id.take().map(RojoRef::new);
|
||||||
let schema = instance.schema.take();
|
let schema = instance.schema.take();
|
||||||
|
|
||||||
@@ -62,7 +56,8 @@ pub fn snapshot_json_model(
|
|||||||
.relevant_paths(vec![vfs.canonicalize(path)?])
|
.relevant_paths(vec![vfs.canonicalize(path)?])
|
||||||
.context(context)
|
.context(context)
|
||||||
.specified_id(id)
|
.specified_id(id)
|
||||||
.schema(schema);
|
.schema(schema)
|
||||||
|
.specified_name(specified_name);
|
||||||
|
|
||||||
Ok(Some(snapshot))
|
Ok(Some(snapshot))
|
||||||
}
|
}
|
||||||
@@ -81,6 +76,7 @@ pub fn syncback_json_model<'sync>(
|
|||||||
// schemas will ever exist in one project for it to matter, but it
|
// schemas will ever exist in one project for it to matter, but it
|
||||||
// could have a performance cost.
|
// could have a performance cost.
|
||||||
model.schema = old_inst.metadata().schema.clone();
|
model.schema = old_inst.metadata().schema.clone();
|
||||||
|
model.name = old_inst.metadata().specified_name.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SyncbackReturn {
|
Ok(SyncbackReturn {
|
||||||
|
|||||||
@@ -158,8 +158,23 @@ pub fn syncback_lua<'sync>(
|
|||||||
|
|
||||||
if !meta.is_empty() {
|
if !meta.is_empty() {
|
||||||
let parent_location = snapshot.path.parent_err()?;
|
let parent_location = snapshot.path.parent_err()?;
|
||||||
|
let file_name = snapshot
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let meta_stem = file_name
|
||||||
|
.strip_suffix(".server.luau")
|
||||||
|
.or_else(|| file_name.strip_suffix(".server.lua"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".client.luau"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".client.lua"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".plugin.luau"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".plugin.lua"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".luau"))
|
||||||
|
.or_else(|| file_name.strip_suffix(".lua"))
|
||||||
|
.unwrap_or(file_name);
|
||||||
fs_snapshot.add_file(
|
fs_snapshot.add_file(
|
||||||
parent_location.join(format!("{}.meta.json", new_inst.name)),
|
parent_location.join(format!("{meta_stem}.meta.json")),
|
||||||
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
|
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,7 +197,6 @@ pub fn syncback_lua_init<'sync>(
|
|||||||
ScriptType::Server => "init.server.luau",
|
ScriptType::Server => "init.server.luau",
|
||||||
ScriptType::Client => "init.client.luau",
|
ScriptType::Client => "init.client.luau",
|
||||||
ScriptType::Module => "init.luau",
|
ScriptType::Module => "init.luau",
|
||||||
ScriptType::Plugin => "init.plugin.luau",
|
|
||||||
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
|
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use rbx_dom_weak::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, syncback::SyncbackSnapshot,
|
json,
|
||||||
|
resolution::UnresolvedValue,
|
||||||
|
snapshot::InstanceSnapshot,
|
||||||
|
syncback::{validate_file_name, SyncbackSnapshot},
|
||||||
RojoRef,
|
RojoRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +39,9 @@ pub struct AdjacentMetadata {
|
|||||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||||
pub attributes: IndexMap<String, UnresolvedValue>,
|
pub attributes: IndexMap<String, UnresolvedValue>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -144,6 +150,26 @@ impl AdjacentMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name = snapshot
|
||||||
|
.old_inst()
|
||||||
|
.and_then(|inst| inst.metadata().specified_name.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
// Write name when name_for_inst would produce a different
|
||||||
|
// filesystem stem (slugification or init-prefix).
|
||||||
|
if snapshot.old_inst().is_none() {
|
||||||
|
let instance_name = &snapshot.new_inst().name;
|
||||||
|
if validate_file_name(instance_name).is_err()
|
||||||
|
|| instance_name.to_lowercase() == "init"
|
||||||
|
{
|
||||||
|
Some(instance_name.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Some(Self {
|
Ok(Some(Self {
|
||||||
ignore_unknown_instances: if ignore_unknown_instances {
|
ignore_unknown_instances: if ignore_unknown_instances {
|
||||||
Some(true)
|
Some(true)
|
||||||
@@ -155,6 +181,7 @@ impl AdjacentMetadata {
|
|||||||
path,
|
path,
|
||||||
id: None,
|
id: None,
|
||||||
schema,
|
schema,
|
||||||
|
name,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,11 +240,26 @@ impl AdjacentMetadata {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||||
|
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot specify a name using {} (instance has a name from somewhere else)",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
snapshot.name = name.clone().into();
|
||||||
|
}
|
||||||
|
snapshot.metadata.specified_name = self.name.take();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||||
self.apply_ignore_unknown_instances(snapshot);
|
self.apply_ignore_unknown_instances(snapshot);
|
||||||
self.apply_properties(snapshot)?;
|
self.apply_properties(snapshot)?;
|
||||||
self.apply_id(snapshot)?;
|
self.apply_id(snapshot)?;
|
||||||
self.apply_schema(snapshot)?;
|
self.apply_schema(snapshot)?;
|
||||||
|
self.apply_name(snapshot)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,11 +268,13 @@ impl AdjacentMetadata {
|
|||||||
///
|
///
|
||||||
/// - The number of properties and attributes is 0
|
/// - The number of properties and attributes is 0
|
||||||
/// - `ignore_unknown_instances` is None
|
/// - `ignore_unknown_instances` is None
|
||||||
|
/// - `name` is None
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.attributes.is_empty()
|
self.attributes.is_empty()
|
||||||
&& self.properties.is_empty()
|
&& self.properties.is_empty()
|
||||||
&& self.ignore_unknown_instances.is_none()
|
&& self.ignore_unknown_instances.is_none()
|
||||||
|
&& self.name.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add method to allow selectively applying parts of metadata and
|
// TODO: Add method to allow selectively applying parts of metadata and
|
||||||
@@ -262,6 +306,9 @@ pub struct DirectoryMetadata {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub class_name: Option<Ustr>,
|
pub class_name: Option<Ustr>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -372,6 +419,26 @@ impl DirectoryMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name = snapshot
|
||||||
|
.old_inst()
|
||||||
|
.and_then(|inst| inst.metadata().specified_name.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
// Write name when name_for_inst would produce a different
|
||||||
|
// directory name (slugification or init-prefix).
|
||||||
|
if snapshot.old_inst().is_none() {
|
||||||
|
let instance_name = &snapshot.new_inst().name;
|
||||||
|
if validate_file_name(instance_name).is_err()
|
||||||
|
|| instance_name.to_lowercase() == "init"
|
||||||
|
{
|
||||||
|
Some(instance_name.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Some(Self {
|
Ok(Some(Self {
|
||||||
ignore_unknown_instances: if ignore_unknown_instances {
|
ignore_unknown_instances: if ignore_unknown_instances {
|
||||||
Some(true)
|
Some(true)
|
||||||
@@ -384,6 +451,7 @@ impl DirectoryMetadata {
|
|||||||
path,
|
path,
|
||||||
id: None,
|
id: None,
|
||||||
schema,
|
schema,
|
||||||
|
name,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +461,7 @@ impl DirectoryMetadata {
|
|||||||
self.apply_properties(snapshot)?;
|
self.apply_properties(snapshot)?;
|
||||||
self.apply_id(snapshot)?;
|
self.apply_id(snapshot)?;
|
||||||
self.apply_schema(snapshot)?;
|
self.apply_schema(snapshot)?;
|
||||||
|
self.apply_name(snapshot)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -464,17 +533,33 @@ impl DirectoryMetadata {
|
|||||||
snapshot.metadata.schema = self.schema.take();
|
snapshot.metadata.schema = self.schema.take();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||||
|
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot specify a name using {} (instance has a name from somewhere else)",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
snapshot.name = name.clone().into();
|
||||||
|
}
|
||||||
|
snapshot.metadata.specified_name = self.name.take();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// Returns whether the metadata is 'empty', meaning it doesn't have anything
|
/// Returns whether the metadata is 'empty', meaning it doesn't have anything
|
||||||
/// worth persisting in it. Specifically:
|
/// worth persisting in it. Specifically:
|
||||||
///
|
///
|
||||||
/// - The number of properties and attributes is 0
|
/// - The number of properties and attributes is 0
|
||||||
/// - `ignore_unknown_instances` is None
|
/// - `ignore_unknown_instances` is None
|
||||||
/// - `class_name` is either None or not Some("Folder")
|
/// - `class_name` is either None or not Some("Folder")
|
||||||
|
/// - `name` is None
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.attributes.is_empty()
|
self.attributes.is_empty()
|
||||||
&& self.properties.is_empty()
|
&& self.properties.is_empty()
|
||||||
&& self.ignore_unknown_instances.is_none()
|
&& self.ignore_unknown_instances.is_none()
|
||||||
|
&& self.name.is_none()
|
||||||
&& if let Some(class) = &self.class_name {
|
&& if let Some(class) = &self.class_name {
|
||||||
class == "Folder"
|
class == "Folder"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ pub use self::{
|
|||||||
/// This will inspect the path and find the appropriate middleware for it,
|
/// This will inspect the path and find the appropriate middleware for it,
|
||||||
/// taking user-written rules into account. Then, it will attempt to convert
|
/// taking user-written rules into account. Then, it will attempt to convert
|
||||||
/// the path into an InstanceSnapshot using that middleware.
|
/// the path into an InstanceSnapshot using that middleware.
|
||||||
|
///
|
||||||
|
/// If a git filter is active in the context and the path is not acknowledged
|
||||||
|
/// (i.e., the file hasn't changed since the base git reference), this function
|
||||||
|
/// returns `Ok(None)` to skip syncing that file.
|
||||||
#[profiling::function]
|
#[profiling::function]
|
||||||
pub fn snapshot_from_vfs(
|
pub fn snapshot_from_vfs(
|
||||||
context: &InstanceContext,
|
context: &InstanceContext,
|
||||||
@@ -72,6 +76,16 @@ pub fn snapshot_from_vfs(
|
|||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if this path is acknowledged by the git filter.
|
||||||
|
// If not, skip this path entirely.
|
||||||
|
if !context.is_path_acknowledged(path) {
|
||||||
|
log::trace!(
|
||||||
|
"Skipping path {} (not acknowledged by git filter)",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
if meta.is_dir() {
|
if meta.is_dir() {
|
||||||
let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?;
|
let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?;
|
||||||
// TODO: Support user defined init paths
|
// TODO: Support user defined init paths
|
||||||
@@ -91,9 +105,7 @@ pub fn snapshot_from_vfs(
|
|||||||
// TODO: Is this even necessary anymore?
|
// TODO: Is this even necessary anymore?
|
||||||
match file_name {
|
match file_name {
|
||||||
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
|
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
|
||||||
| "init.plugin.luau" | "init.plugin.lua" | "init.luau" | "init.lua" | "init.csv" => {
|
| "init.luau" | "init.lua" | "init.csv" => return Ok(None),
|
||||||
return Ok(None)
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +138,6 @@ fn get_dir_middleware<'path>(
|
|||||||
(Middleware::ServerScriptDir, "init.server.lua"),
|
(Middleware::ServerScriptDir, "init.server.lua"),
|
||||||
(Middleware::ClientScriptDir, "init.client.luau"),
|
(Middleware::ClientScriptDir, "init.client.luau"),
|
||||||
(Middleware::ClientScriptDir, "init.client.lua"),
|
(Middleware::ClientScriptDir, "init.client.lua"),
|
||||||
(Middleware::PluginScriptDir, "init.plugin.lua"),
|
|
||||||
(Middleware::PluginScriptDir, "init.plugin.luau"),
|
|
||||||
(Middleware::CsvDir, "init.csv"),
|
(Middleware::CsvDir, "init.csv"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -209,8 +219,6 @@ pub enum Middleware {
|
|||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
ClientScriptDir,
|
ClientScriptDir,
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
PluginScriptDir,
|
|
||||||
#[serde(skip_deserializing)]
|
|
||||||
ModuleScriptDir,
|
ModuleScriptDir,
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
CsvDir,
|
CsvDir,
|
||||||
@@ -219,6 +227,10 @@ pub enum Middleware {
|
|||||||
impl Middleware {
|
impl Middleware {
|
||||||
/// Creates a snapshot for the given path from the Middleware with
|
/// Creates a snapshot for the given path from the Middleware with
|
||||||
/// the provided name.
|
/// the provided name.
|
||||||
|
///
|
||||||
|
/// When a git filter is active in the context, `ignore_unknown_instances`
|
||||||
|
/// will be set to `true` on all generated snapshots to preserve descendants
|
||||||
|
/// in Studio that are not tracked by Rojo.
|
||||||
fn snapshot(
|
fn snapshot(
|
||||||
&self,
|
&self,
|
||||||
context: &InstanceContext,
|
context: &InstanceContext,
|
||||||
@@ -261,9 +273,6 @@ impl Middleware {
|
|||||||
Self::ClientScriptDir => {
|
Self::ClientScriptDir => {
|
||||||
snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
|
snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
|
||||||
}
|
}
|
||||||
Self::PluginScriptDir => {
|
|
||||||
snapshot_lua_init(context, vfs, path, name, ScriptType::Plugin)
|
|
||||||
}
|
|
||||||
Self::ModuleScriptDir => {
|
Self::ModuleScriptDir => {
|
||||||
snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
|
snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
|
||||||
}
|
}
|
||||||
@@ -271,6 +280,14 @@ impl Middleware {
|
|||||||
};
|
};
|
||||||
if let Ok(Some(ref mut snapshot)) = output {
|
if let Ok(Some(ref mut snapshot)) = output {
|
||||||
snapshot.metadata.middleware = Some(*self);
|
snapshot.metadata.middleware = Some(*self);
|
||||||
|
|
||||||
|
// When git filter is active, force ignore_unknown_instances to true
|
||||||
|
// so that we don't delete children in Studio that aren't tracked.
|
||||||
|
if context.has_git_filter() {
|
||||||
|
snapshot.metadata.ignore_unknown_instances = true;
|
||||||
|
// Also apply this recursively to all children
|
||||||
|
set_ignore_unknown_instances_recursive(&mut snapshot.children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
@@ -306,7 +323,6 @@ impl Middleware {
|
|||||||
Middleware::Dir => syncback_dir(snapshot),
|
Middleware::Dir => syncback_dir(snapshot),
|
||||||
Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
|
Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
|
||||||
Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot),
|
Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot),
|
||||||
Middleware::PluginScriptDir => syncback_lua_init(ScriptType::Plugin, snapshot),
|
|
||||||
Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
|
Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
|
||||||
Middleware::CsvDir => syncback_csv_init(snapshot),
|
Middleware::CsvDir => syncback_csv_init(snapshot),
|
||||||
|
|
||||||
@@ -328,7 +344,6 @@ impl Middleware {
|
|||||||
Middleware::Dir
|
Middleware::Dir
|
||||||
| Middleware::ServerScriptDir
|
| Middleware::ServerScriptDir
|
||||||
| Middleware::ClientScriptDir
|
| Middleware::ClientScriptDir
|
||||||
| Middleware::PluginScriptDir
|
|
||||||
| Middleware::ModuleScriptDir
|
| Middleware::ModuleScriptDir
|
||||||
| Middleware::CsvDir
|
| Middleware::CsvDir
|
||||||
)
|
)
|
||||||
@@ -376,6 +391,16 @@ impl Middleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursively sets `ignore_unknown_instances` to `true` on all children.
|
||||||
|
/// This is used when git filter is active to ensure we don't delete
|
||||||
|
/// children in Studio that aren't tracked by Rojo.
|
||||||
|
fn set_ignore_unknown_instances_recursive(children: &mut [InstanceSnapshot]) {
|
||||||
|
for child in children {
|
||||||
|
child.metadata.ignore_unknown_instances = true;
|
||||||
|
set_ignore_unknown_instances_recursive(&mut child.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A helper for easily defining a SyncRule. Arguments are passed literally
|
/// A helper for easily defining a SyncRule. Arguments are passed literally
|
||||||
/// to this macro in the order `include`, `middleware`, `suffix`,
|
/// to this macro in the order `include`, `middleware`, `suffix`,
|
||||||
/// and `exclude`. Both `suffix` and `exclude` are optional.
|
/// and `exclude`. Both `suffix` and `exclude` are optional.
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ pub fn snapshot_project(
|
|||||||
// file being updated.
|
// file being updated.
|
||||||
snapshot.metadata.relevant_paths.push(path.to_path_buf());
|
snapshot.metadata.relevant_paths.push(path.to_path_buf());
|
||||||
|
|
||||||
|
// When git filter is active, also register the project folder as a
|
||||||
|
// relevant path. This serves as a catch-all so that file changes
|
||||||
|
// not under any specific $path node can still walk up the directory
|
||||||
|
// tree and trigger a re-snapshot of the entire project.
|
||||||
|
if context.has_git_filter() {
|
||||||
|
if let Some(folder) = path.parent() {
|
||||||
|
let normalized = vfs
|
||||||
|
.canonicalize(folder)
|
||||||
|
.unwrap_or_else(|_| folder.to_path_buf());
|
||||||
|
snapshot.metadata.relevant_paths.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(snapshot))
|
Ok(Some(snapshot))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
@@ -137,6 +150,26 @@ pub fn snapshot_project_node(
|
|||||||
// Take the snapshot's metadata as-is, which will be mutated later
|
// Take the snapshot's metadata as-is, which will be mutated later
|
||||||
// on.
|
// on.
|
||||||
metadata = snapshot.metadata;
|
metadata = snapshot.metadata;
|
||||||
|
} else if context.has_git_filter() {
|
||||||
|
// When the git filter is active and the $path was filtered out
|
||||||
|
// (no acknowledged files yet), we still need to register the path
|
||||||
|
// in relevant_paths. This allows the change processor to map file
|
||||||
|
// changes in this directory back to this project node instance,
|
||||||
|
// triggering a re-snapshot that will pick up newly modified files.
|
||||||
|
let normalized = vfs
|
||||||
|
.canonicalize(full_path.as_ref())
|
||||||
|
.unwrap_or_else(|_| full_path.to_path_buf());
|
||||||
|
metadata.relevant_paths.push(normalized);
|
||||||
|
|
||||||
|
// The VFS only sets up file watches via read() and read_dir(),
|
||||||
|
// not via metadata(). Since the git filter caused snapshot_from_vfs
|
||||||
|
// to return early (before read_dir was called), the VFS is not
|
||||||
|
// watching this path. We must read the directory here to ensure
|
||||||
|
// the VFS sets up a recursive watch, otherwise file change events
|
||||||
|
// will never fire and live sync won't detect modifications.
|
||||||
|
if full_path.is_dir() {
|
||||||
|
let _ = vfs.read_dir(&full_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +225,17 @@ pub fn snapshot_project_node(
|
|||||||
}
|
}
|
||||||
|
|
||||||
(_, None, _, Some(PathNode::Required(path))) => {
|
(_, None, _, Some(PathNode::Required(path))) => {
|
||||||
|
// If git filter is active and the path was filtered out, treat it
|
||||||
|
// as if the path was optional and skip this node.
|
||||||
|
if context.has_git_filter() {
|
||||||
|
log::trace!(
|
||||||
|
"Skipping project node '{}' because its path was filtered by git filter: {}",
|
||||||
|
instance_name,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Rojo project referred to a file using $path that could not be turned into a Roblox Instance by Rojo.\n\
|
"Rojo project referred to a file using $path that could not be turned into a Roblox Instance by Rojo.\n\
|
||||||
Check that the file exists and is a file type known by Rojo.\n\
|
Check that the file exists and is a file type known by Rojo.\n\
|
||||||
@@ -282,7 +326,12 @@ pub fn snapshot_project_node(
|
|||||||
// If the user didn't specify it AND $path was not specified (meaning
|
// If the user didn't specify it AND $path was not specified (meaning
|
||||||
// there's no existing value we'd be stepping on from a project file or meta
|
// there's no existing value we'd be stepping on from a project file or meta
|
||||||
// file), set it to true.
|
// file), set it to true.
|
||||||
if let Some(ignore) = node.ignore_unknown_instances {
|
//
|
||||||
|
// When git filter is active, always set to true to preserve descendants
|
||||||
|
// in Studio that are not tracked by Rojo.
|
||||||
|
if context.has_git_filter() {
|
||||||
|
metadata.ignore_unknown_instances = true;
|
||||||
|
} else if let Some(ignore) = node.ignore_unknown_instances {
|
||||||
metadata.ignore_unknown_instances = ignore;
|
metadata.ignore_unknown_instances = ignore;
|
||||||
} else if node.path.is_none() {
|
} else if node.path.is_none() {
|
||||||
// TODO: Introduce a strict mode where $ignoreUnknownInstances is never
|
// TODO: Introduce a strict mode where $ignoreUnknownInstances is never
|
||||||
@@ -344,11 +393,6 @@ pub fn syncback_project<'sync>(
|
|||||||
let mut new_child_map = HashMap::new();
|
let mut new_child_map = HashMap::new();
|
||||||
|
|
||||||
let mut node_changed_map = Vec::new();
|
let mut node_changed_map = Vec::new();
|
||||||
// Tracks whether any stale default-valued properties were removed from
|
|
||||||
// project nodes. If so, we must reserialize even if
|
|
||||||
// project_node_should_reserialize wouldn't otherwise detect a change
|
|
||||||
// (it only compares node properties forward, not in reverse).
|
|
||||||
let mut removed_stale_properties = false;
|
|
||||||
let mut node_queue = VecDeque::with_capacity(1);
|
let mut node_queue = VecDeque::with_capacity(1);
|
||||||
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
|
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
|
||||||
|
|
||||||
@@ -407,12 +451,10 @@ pub fn syncback_project<'sync>(
|
|||||||
|
|
||||||
// We only want to set properties if it needs it.
|
// We only want to set properties if it needs it.
|
||||||
if !middleware.handles_own_properties() {
|
if !middleware.handles_own_properties() {
|
||||||
removed_stale_properties |=
|
project_node_property_syncback_path(snapshot, new_inst, node);
|
||||||
project_node_property_syncback_path(snapshot, new_inst, node);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
removed_stale_properties |=
|
project_node_property_syncback_no_path(snapshot, new_inst, node);
|
||||||
project_node_property_syncback_no_path(snapshot, new_inst, node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for child_ref in new_inst.children() {
|
for child_ref in new_inst.children() {
|
||||||
@@ -514,18 +556,12 @@ pub fn syncback_project<'sync>(
|
|||||||
}
|
}
|
||||||
let mut fs_snapshot = FsSnapshot::new();
|
let mut fs_snapshot = FsSnapshot::new();
|
||||||
|
|
||||||
let mut needs_reserialize = removed_stale_properties;
|
for (node_properties, node_attributes, old_inst) in node_changed_map {
|
||||||
if !needs_reserialize {
|
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
|
||||||
for (node_properties, node_attributes, old_inst) in node_changed_map {
|
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
|
||||||
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
|
break;
|
||||||
needs_reserialize = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if needs_reserialize {
|
|
||||||
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SyncbackReturn {
|
Ok(SyncbackReturn {
|
||||||
fs_snapshot,
|
fs_snapshot,
|
||||||
@@ -534,18 +570,15 @@ pub fn syncback_project<'sync>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Syncs properties from the new instance into the project node.
|
|
||||||
/// Returns `true` if any stale properties were removed (i.e. properties
|
|
||||||
/// that existed in the project node but are now at their engine default).
|
|
||||||
fn project_node_property_syncback(
|
fn project_node_property_syncback(
|
||||||
_snapshot: &SyncbackSnapshot,
|
_snapshot: &SyncbackSnapshot,
|
||||||
filtered_properties: UstrMap<&Variant>,
|
filtered_properties: UstrMap<&Variant>,
|
||||||
new_inst: &Instance,
|
new_inst: &Instance,
|
||||||
node: &mut ProjectNode,
|
node: &mut ProjectNode,
|
||||||
) -> bool {
|
) {
|
||||||
let properties = &mut node.properties;
|
let properties = &mut node.properties;
|
||||||
let mut attributes = BTreeMap::new();
|
let mut attributes = BTreeMap::new();
|
||||||
for (&name, &value) in &filtered_properties {
|
for (name, value) in filtered_properties {
|
||||||
match value {
|
match value {
|
||||||
Variant::Attributes(attrs) => {
|
Variant::Attributes(attrs) => {
|
||||||
for (attr_name, attr_value) in attrs.iter() {
|
for (attr_name, attr_value) in attrs.iter() {
|
||||||
@@ -568,48 +601,14 @@ fn project_node_property_syncback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stale properties: entries that exist in the project node's
|
|
||||||
// $properties but are no longer in the filtered (non-default) properties
|
|
||||||
// from the instance. This handles the case where Studio resets a property
|
|
||||||
// to its engine default — filter_properties won't include it, so we need
|
|
||||||
// to clean up the now-stale project entry.
|
|
||||||
let class_data = rbx_reflection_database::get()
|
|
||||||
.ok()
|
|
||||||
.and_then(|db| db.classes.get(new_inst.class.as_str()));
|
|
||||||
let len_before = properties.len();
|
|
||||||
properties.retain(|prop_name, _| {
|
|
||||||
if filtered_properties.contains_key(prop_name) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Only remove if the property has a known default value in the
|
|
||||||
// reflection database. If there's no default, the property might be
|
|
||||||
// absent from the instance for other reasons (e.g. unknown property),
|
|
||||||
// so we conservatively keep it.
|
|
||||||
if let Some(data) = &class_data {
|
|
||||||
if data.default_properties.contains_key(prop_name.as_str()) {
|
|
||||||
log::debug!(
|
|
||||||
"Removing stale property '{}' from project node for class '{}': \
|
|
||||||
value has been reset to engine default",
|
|
||||||
prop_name,
|
|
||||||
new_inst.class
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
});
|
|
||||||
let removed_stale = properties.len() < len_before;
|
|
||||||
|
|
||||||
node.attributes = attributes;
|
node.attributes = attributes;
|
||||||
removed_stale
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_node_property_syncback_path(
|
fn project_node_property_syncback_path(
|
||||||
snapshot: &SyncbackSnapshot,
|
snapshot: &SyncbackSnapshot,
|
||||||
new_inst: &Instance,
|
new_inst: &Instance,
|
||||||
node: &mut ProjectNode,
|
node: &mut ProjectNode,
|
||||||
) -> bool {
|
) {
|
||||||
let filtered_properties = snapshot
|
let filtered_properties = snapshot
|
||||||
.get_path_filtered_properties(new_inst.referent())
|
.get_path_filtered_properties(new_inst.referent())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -620,7 +619,7 @@ fn project_node_property_syncback_no_path(
|
|||||||
snapshot: &SyncbackSnapshot,
|
snapshot: &SyncbackSnapshot,
|
||||||
new_inst: &Instance,
|
new_inst: &Instance,
|
||||||
node: &mut ProjectNode,
|
node: &mut ProjectNode,
|
||||||
) -> bool {
|
) {
|
||||||
let filtered_properties = filter_properties(snapshot.project(), new_inst);
|
let filtered_properties = filter_properties(snapshot.project(), new_inst);
|
||||||
project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
|
project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /root/init.server.luau
|
- /root/init.server.luau
|
||||||
- /root/init.client.lua
|
- /root/init.client.lua
|
||||||
- /root/init.client.luau
|
- /root/init.client.luau
|
||||||
- /root/init.plugin.lua
|
|
||||||
- /root/init.plugin.luau
|
|
||||||
- /root/init.csv
|
- /root/init.csv
|
||||||
- /root/init.meta.json
|
- /root/init.meta.json
|
||||||
- /root/init.meta.jsonc
|
- /root/init.meta.jsonc
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /root/init.server.luau
|
- /root/init.server.luau
|
||||||
- /root/init.client.lua
|
- /root/init.client.lua
|
||||||
- /root/init.client.luau
|
- /root/init.client.luau
|
||||||
- /root/init.plugin.lua
|
|
||||||
- /root/init.plugin.luau
|
|
||||||
- /root/init.csv
|
- /root/init.csv
|
||||||
- /root/init.meta.json
|
- /root/init.meta.json
|
||||||
- /root/init.meta.jsonc
|
- /root/init.meta.jsonc
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /foo/init.server.luau
|
- /foo/init.server.luau
|
||||||
- /foo/init.client.lua
|
- /foo/init.client.lua
|
||||||
- /foo/init.client.luau
|
- /foo/init.client.luau
|
||||||
- /foo/init.plugin.lua
|
|
||||||
- /foo/init.plugin.luau
|
|
||||||
- /foo/init.csv
|
- /foo/init.csv
|
||||||
- /foo/init.meta.json
|
- /foo/init.meta.json
|
||||||
- /foo/init.meta.jsonc
|
- /foo/init.meta.jsonc
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /foo/init.server.luau
|
- /foo/init.server.luau
|
||||||
- /foo/init.client.lua
|
- /foo/init.client.lua
|
||||||
- /foo/init.client.luau
|
- /foo/init.client.luau
|
||||||
- /foo/init.plugin.lua
|
|
||||||
- /foo/init.plugin.luau
|
|
||||||
- /foo/init.csv
|
- /foo/init.csv
|
||||||
- /foo/init.meta.json
|
- /foo/init.meta.json
|
||||||
- /foo/init.meta.jsonc
|
- /foo/init.meta.jsonc
|
||||||
@@ -42,8 +40,6 @@ children:
|
|||||||
- /foo/Child/init.server.luau
|
- /foo/Child/init.server.luau
|
||||||
- /foo/Child/init.client.lua
|
- /foo/Child/init.client.lua
|
||||||
- /foo/Child/init.client.luau
|
- /foo/Child/init.client.luau
|
||||||
- /foo/Child/init.plugin.lua
|
|
||||||
- /foo/Child/init.plugin.luau
|
|
||||||
- /foo/Child/init.csv
|
- /foo/Child/init.csv
|
||||||
- /foo/Child/init.meta.json
|
- /foo/Child/init.meta.json
|
||||||
- /foo/Child/init.meta.jsonc
|
- /foo/Child/init.meta.jsonc
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /root/init.server.luau
|
- /root/init.server.luau
|
||||||
- /root/init.client.lua
|
- /root/init.client.lua
|
||||||
- /root/init.client.luau
|
- /root/init.client.luau
|
||||||
- /root/init.plugin.lua
|
|
||||||
- /root/init.plugin.luau
|
|
||||||
- /root/init.csv
|
- /root/init.csv
|
||||||
- /root/init.meta.json
|
- /root/init.meta.json
|
||||||
- /root/init.meta.jsonc
|
- /root/init.meta.jsonc
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ metadata:
|
|||||||
- /root/init.server.luau
|
- /root/init.server.luau
|
||||||
- /root/init.client.lua
|
- /root/init.client.lua
|
||||||
- /root/init.client.luau
|
- /root/init.client.luau
|
||||||
- /root/init.plugin.lua
|
|
||||||
- /root/init.plugin.luau
|
|
||||||
- /root/init.csv
|
- /root/init.csv
|
||||||
- /root/init.meta.json
|
- /root/init.meta.json
|
||||||
- /root/init.meta.jsonc
|
- /root/init.meta.jsonc
|
||||||
|
|||||||
@@ -58,8 +58,14 @@ pub fn syncback_txt<'sync>(
|
|||||||
|
|
||||||
if !meta.is_empty() {
|
if !meta.is_empty() {
|
||||||
let parent = snapshot.path.parent_err()?;
|
let parent = snapshot.path.parent_err()?;
|
||||||
|
let file_name = snapshot
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let meta_stem = file_name.strip_suffix(".txt").unwrap_or(file_name);
|
||||||
fs_snapshot.add_file(
|
fs_snapshot.add_file(
|
||||||
parent.join(format!("{}.meta.json", new_inst.name)),
|
parent.join(format!("{meta_stem}.meta.json")),
|
||||||
serde_json::to_vec_pretty(&meta).context("could not serialize metadata")?,
|
serde_json::to_vec_pretty(&meta).context("could not serialize metadata")?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ use rbx_dom_weak::Instance;
|
|||||||
|
|
||||||
use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
|
use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
|
||||||
|
|
||||||
pub fn name_for_inst<'old>(
|
pub fn name_for_inst<'a>(
|
||||||
middleware: Middleware,
|
middleware: Middleware,
|
||||||
new_inst: &Instance,
|
new_inst: &'a Instance,
|
||||||
old_inst: Option<InstanceWithMeta<'old>>,
|
old_inst: Option<InstanceWithMeta<'a>>,
|
||||||
) -> anyhow::Result<Cow<'old, str>> {
|
) -> anyhow::Result<Cow<'a, str>> {
|
||||||
if let Some(old_inst) = old_inst {
|
if let Some(old_inst) = old_inst {
|
||||||
if let Some(source) = old_inst.metadata().relevant_paths.first() {
|
if let Some(source) = old_inst.metadata().relevant_paths.first() {
|
||||||
source
|
source
|
||||||
@@ -35,15 +35,34 @@ pub fn name_for_inst<'old>(
|
|||||||
| Middleware::CsvDir
|
| Middleware::CsvDir
|
||||||
| Middleware::ServerScriptDir
|
| Middleware::ServerScriptDir
|
||||||
| Middleware::ClientScriptDir
|
| Middleware::ClientScriptDir
|
||||||
| Middleware::PluginScriptDir
|
| Middleware::ModuleScriptDir => {
|
||||||
| Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()),
|
let name = if validate_file_name(&new_inst.name).is_err() {
|
||||||
|
Cow::Owned(slugify_name(&new_inst.name))
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(new_inst.name.as_str())
|
||||||
|
};
|
||||||
|
// Prefix "init" to avoid colliding with reserved init files.
|
||||||
|
if name.to_lowercase() == "init" {
|
||||||
|
Cow::Owned(format!("_{name}"))
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let extension = extension_for_middleware(middleware);
|
let extension = extension_for_middleware(middleware);
|
||||||
let name = &new_inst.name;
|
let slugified;
|
||||||
validate_file_name(name).with_context(|| {
|
let stem: &str = if validate_file_name(&new_inst.name).is_err() {
|
||||||
format!("name '{name}' is not legal to write to the file system")
|
slugified = slugify_name(&new_inst.name);
|
||||||
})?;
|
&slugified
|
||||||
Cow::Owned(format!("{name}.{extension}"))
|
} else {
|
||||||
|
&new_inst.name
|
||||||
|
};
|
||||||
|
// Prefix "init" stems to avoid colliding with reserved init files.
|
||||||
|
if stem.to_lowercase() == "init" {
|
||||||
|
Cow::Owned(format!("_{stem}.{extension}"))
|
||||||
|
} else {
|
||||||
|
Cow::Owned(format!("{stem}.{extension}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -79,7 +98,6 @@ pub fn extension_for_middleware(middleware: Middleware) -> &'static str {
|
|||||||
| Middleware::CsvDir
|
| Middleware::CsvDir
|
||||||
| Middleware::ServerScriptDir
|
| Middleware::ServerScriptDir
|
||||||
| Middleware::ClientScriptDir
|
| Middleware::ClientScriptDir
|
||||||
| Middleware::PluginScriptDir
|
|
||||||
| Middleware::ModuleScriptDir => {
|
| Middleware::ModuleScriptDir => {
|
||||||
unimplemented!("directory middleware requires special treatment")
|
unimplemented!("directory middleware requires special treatment")
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,39 @@ const INVALID_WINDOWS_NAMES: [&str; 22] = [
|
|||||||
/// in a file's name.
|
/// in a file's name.
|
||||||
const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\'];
|
const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\'];
|
||||||
|
|
||||||
|
/// Slugifies a name by replacing forbidden characters with underscores
|
||||||
|
/// and ensuring the result is a valid file name
|
||||||
|
pub fn slugify_name(name: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(name.len());
|
||||||
|
|
||||||
|
for ch in name.chars() {
|
||||||
|
if FORBIDDEN_CHARS.contains(&ch) {
|
||||||
|
result.push('_');
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Windows reserved names by appending an underscore
|
||||||
|
let result_lower = result.to_lowercase();
|
||||||
|
for forbidden in INVALID_WINDOWS_NAMES {
|
||||||
|
if result_lower == forbidden.to_lowercase() {
|
||||||
|
result.push('_');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while result.ends_with(' ') || result.ends_with('.') {
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() || result.chars().all(|c| c == '_') {
|
||||||
|
result = "instance".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates a provided file name to ensure it's allowed on the file system. An
|
/// Validates a provided file name to ensure it's allowed on the file system. An
|
||||||
/// error is returned if the name isn't allowed, indicating why.
|
/// error is returned if the name isn't allowed, indicating why.
|
||||||
/// This takes into account rules for Windows, MacOS, and Linux.
|
/// This takes into account rules for Windows, MacOS, and Linux.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
glob::{Glob, IgnorableGlob},
|
glob::Glob,
|
||||||
snapshot::{InstanceWithMeta, RojoTree},
|
snapshot::{InstanceWithMeta, RojoTree},
|
||||||
snapshot_middleware::Middleware,
|
snapshot_middleware::Middleware,
|
||||||
syncback::ref_properties::{collect_referents, link_referents},
|
syncback::ref_properties::{collect_referents, link_referents},
|
||||||
@@ -52,6 +52,7 @@ pub fn syncback_loop(
|
|||||||
old_tree: &mut RojoTree,
|
old_tree: &mut RojoTree,
|
||||||
mut new_tree: WeakDom,
|
mut new_tree: WeakDom,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
|
force_json: bool,
|
||||||
) -> anyhow::Result<FsSnapshot> {
|
) -> anyhow::Result<FsSnapshot> {
|
||||||
let ignore_patterns = project
|
let ignore_patterns = project
|
||||||
.syncback_rules
|
.syncback_rules
|
||||||
@@ -153,6 +154,7 @@ pub fn syncback_loop(
|
|||||||
old_tree,
|
old_tree,
|
||||||
new_tree: &new_tree,
|
new_tree: &new_tree,
|
||||||
project,
|
project,
|
||||||
|
force_json,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut snapshots = vec![SyncbackSnapshot {
|
let mut snapshots = vec![SyncbackSnapshot {
|
||||||
@@ -197,7 +199,7 @@ pub fn syncback_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let middleware = get_best_middleware(&snapshot);
|
let middleware = get_best_middleware(&snapshot, force_json);
|
||||||
|
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Middleware for {inst_path} is {:?} (path is {})",
|
"Middleware for {inst_path} is {:?} (path is {})",
|
||||||
@@ -213,10 +215,14 @@ pub fn syncback_loop(
|
|||||||
let syncback = match middleware.syncback(&snapshot) {
|
let syncback = match middleware.syncback(&snapshot) {
|
||||||
Ok(syncback) => syncback,
|
Ok(syncback) => syncback,
|
||||||
Err(err) if middleware == Middleware::Dir => {
|
Err(err) if middleware == Middleware::Dir => {
|
||||||
let new_middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
let new_middleware = if force_json {
|
||||||
Ok(value) if value == "1" => Middleware::Rbxmx,
|
Middleware::JsonModel
|
||||||
Ok(value) if value == "2" => Middleware::JsonModel,
|
} else {
|
||||||
_ => Middleware::Rbxm,
|
match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
||||||
|
Ok(value) if value == "1" => Middleware::Rbxmx,
|
||||||
|
Ok(value) if value == "2" => Middleware::JsonModel,
|
||||||
|
_ => Middleware::Rbxm,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let file_name = snapshot
|
let file_name = snapshot
|
||||||
.path
|
.path
|
||||||
@@ -295,7 +301,7 @@ pub struct SyncbackReturn<'sync> {
|
|||||||
pub removed_children: Vec<InstanceWithMeta<'sync>>,
|
pub removed_children: Vec<InstanceWithMeta<'sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
|
pub fn get_best_middleware(snapshot: &SyncbackSnapshot, force_json: bool) -> Middleware {
|
||||||
// At some point, we're better off using an O(1) method for checking
|
// At some point, we're better off using an O(1) method for checking
|
||||||
// equality for classes like this.
|
// equality for classes like this.
|
||||||
static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new();
|
static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new();
|
||||||
@@ -359,7 +365,6 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
|
|||||||
middleware = match middleware {
|
middleware = match middleware {
|
||||||
Middleware::ServerScript => Middleware::ServerScriptDir,
|
Middleware::ServerScript => Middleware::ServerScriptDir,
|
||||||
Middleware::ClientScript => Middleware::ClientScriptDir,
|
Middleware::ClientScript => Middleware::ClientScriptDir,
|
||||||
Middleware::PluginScript => Middleware::PluginScriptDir,
|
|
||||||
Middleware::ModuleScript => Middleware::ModuleScriptDir,
|
Middleware::ModuleScript => Middleware::ModuleScriptDir,
|
||||||
Middleware::Csv => Middleware::CsvDir,
|
Middleware::Csv => Middleware::CsvDir,
|
||||||
Middleware::JsonModel | Middleware::Text => Middleware::Dir,
|
Middleware::JsonModel | Middleware::Text => Middleware::Dir,
|
||||||
@@ -368,10 +373,18 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if middleware == Middleware::Rbxm {
|
if middleware == Middleware::Rbxm {
|
||||||
middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
middleware = if force_json {
|
||||||
Ok(value) if value == "1" => Middleware::Rbxmx,
|
if !inst.children().is_empty() {
|
||||||
Ok(value) if value == "2" => Middleware::JsonModel,
|
Middleware::Dir
|
||||||
_ => Middleware::Rbxm,
|
} else {
|
||||||
|
Middleware::JsonModel
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
||||||
|
Ok(value) if value == "1" => Middleware::Rbxmx,
|
||||||
|
Ok(value) if value == "2" => Middleware::JsonModel,
|
||||||
|
_ => Middleware::Rbxm,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,18 +428,18 @@ pub struct SyncbackRules {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SyncbackRules {
|
impl SyncbackRules {
|
||||||
pub fn compile_globs(&self) -> anyhow::Result<Vec<IgnorableGlob>> {
|
pub fn compile_globs(&self) -> anyhow::Result<Vec<Glob>> {
|
||||||
let mut globs = Vec::with_capacity(self.ignore_paths.len());
|
let mut globs = Vec::with_capacity(self.ignore_paths.len());
|
||||||
let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true);
|
let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true);
|
||||||
|
|
||||||
for pattern in &self.ignore_paths {
|
for pattern in &self.ignore_paths {
|
||||||
let glob = IgnorableGlob::new(pattern)
|
let glob = Glob::new(pattern)
|
||||||
.with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?;
|
.with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?;
|
||||||
globs.push(glob);
|
globs.push(glob);
|
||||||
|
|
||||||
if dir_ignore_paths {
|
if dir_ignore_paths {
|
||||||
if let Some(dir_pattern) = pattern.strip_suffix("/**") {
|
if let Some(dir_pattern) = pattern.strip_suffix("/**") {
|
||||||
if let Ok(glob) = IgnorableGlob::new(dir_pattern) {
|
if let Ok(glob) = Glob::new(dir_pattern) {
|
||||||
globs.push(glob)
|
globs.push(glob)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +450,7 @@ impl SyncbackRules {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_path(globs: &Option<Vec<IgnorableGlob>>, base_path: &Path, path: &Path) -> bool {
|
fn is_valid_path(globs: &Option<Vec<Glob>>, base_path: &Path, path: &Path) -> bool {
|
||||||
let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap());
|
let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap());
|
||||||
let test_path = match path.strip_prefix(base_path) {
|
let test_path = match path.strip_prefix(base_path) {
|
||||||
Ok(suffix) => suffix,
|
Ok(suffix) => suffix,
|
||||||
@@ -447,16 +460,11 @@ fn is_valid_path(globs: &Option<Vec<IgnorableGlob>>, base_path: &Path, path: &Pa
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(ref ignore_paths) = globs {
|
if let Some(ref ignore_paths) = globs {
|
||||||
// Gitignore-style "last match wins"
|
|
||||||
let mut ignored = false;
|
|
||||||
for glob in ignore_paths {
|
for glob in ignore_paths {
|
||||||
if glob.is_match(test_path) {
|
if glob.is_match(test_path) {
|
||||||
ignored = !glob.is_negation();
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ignored {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -544,71 +552,3 @@ fn strip_unknown_root_children(new: &mut WeakDom, old: &RojoTree) {
|
|||||||
new.destroy(child_ref);
|
new.destroy(child_ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn rules(ignore_paths: &[&str], create_ignore_dir_paths: Option<bool>) -> SyncbackRules {
|
|
||||||
SyncbackRules {
|
|
||||||
ignore_trees: Vec::new(),
|
|
||||||
ignore_paths: ignore_paths.iter().map(|s| s.to_string()).collect(),
|
|
||||||
ignore_properties: IndexMap::new(),
|
|
||||||
sync_current_camera: None,
|
|
||||||
sync_unscriptable: None,
|
|
||||||
ignore_referents: None,
|
|
||||||
create_ignore_dir_paths,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignore_paths_negation() {
|
|
||||||
let globs = Some(
|
|
||||||
rules(&["**/*.lua", "!keep.lua"], Some(false))
|
|
||||||
.compile_globs()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let base = Path::new("/test");
|
|
||||||
|
|
||||||
// A later negation re-includes a path matched by an earlier pattern.
|
|
||||||
assert!(!is_valid_path(&globs, base, Path::new("/test/foo.lua")));
|
|
||||||
assert!(is_valid_path(&globs, base, Path::new("/test/keep.lua")));
|
|
||||||
// Paths matched by no rule are valid.
|
|
||||||
assert!(is_valid_path(&globs, base, Path::new("/test/plain.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignore_paths_negation_with_dir_expansion() {
|
|
||||||
// With `create_ignore_dir_paths`, a negated `foo/**` pattern should also
|
|
||||||
// re-include the `foo` directory itself, mirroring the file rule.
|
|
||||||
let globs = Some(
|
|
||||||
rules(&["**/*", "!keep/**"], Some(true))
|
|
||||||
.compile_globs()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let base = Path::new("/test");
|
|
||||||
|
|
||||||
assert!(!is_valid_path(&globs, base, Path::new("/test/drop/a.lua")));
|
|
||||||
assert!(is_valid_path(&globs, base, Path::new("/test/keep")));
|
|
||||||
assert!(is_valid_path(&globs, base, Path::new("/test/keep/a.lua")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignore_paths_escaped_bang_is_literal() {
|
|
||||||
// `\!literal.lua` should ignore a file literally named `!literal.lua`
|
|
||||||
// rather than being parsed as a negation.
|
|
||||||
let globs = Some(
|
|
||||||
rules(&[r"\!literal.lua"], Some(false))
|
|
||||||
.compile_globs()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let base = Path::new("/test");
|
|
||||||
|
|
||||||
assert!(!globs.as_ref().unwrap()[0].is_negation());
|
|
||||||
assert!(!is_valid_path(
|
|
||||||
&globs,
|
|
||||||
base,
|
|
||||||
Path::new("/test/!literal.lua")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct SyncbackData<'sync> {
|
|||||||
pub(super) old_tree: &'sync RojoTree,
|
pub(super) old_tree: &'sync RojoTree,
|
||||||
pub(super) new_tree: &'sync WeakDom,
|
pub(super) new_tree: &'sync WeakDom,
|
||||||
pub(super) project: &'sync Project,
|
pub(super) project: &'sync Project,
|
||||||
|
pub(super) force_json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SyncbackSnapshot<'sync> {
|
pub struct SyncbackSnapshot<'sync> {
|
||||||
@@ -31,6 +32,25 @@ pub struct SyncbackSnapshot<'sync> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'sync> SyncbackSnapshot<'sync> {
|
impl<'sync> SyncbackSnapshot<'sync> {
|
||||||
|
/// Computes the middleware and filesystem name for a child without
|
||||||
|
/// creating a full snapshot. Uses the same logic as `with_joined_path`.
|
||||||
|
pub fn child_middleware_and_name(
|
||||||
|
&self,
|
||||||
|
new_ref: Ref,
|
||||||
|
old_ref: Option<Ref>,
|
||||||
|
) -> anyhow::Result<(Middleware, String)> {
|
||||||
|
let temp = Self {
|
||||||
|
data: self.data,
|
||||||
|
old: old_ref,
|
||||||
|
new: new_ref,
|
||||||
|
path: PathBuf::new(),
|
||||||
|
middleware: None,
|
||||||
|
};
|
||||||
|
let middleware = get_best_middleware(&temp, self.data.force_json);
|
||||||
|
let name = name_for_inst(middleware, temp.new_inst(), temp.old_inst())?;
|
||||||
|
Ok((middleware, name.into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Constructs a SyncbackSnapshot from the provided refs
|
/// Constructs a SyncbackSnapshot from the provided refs
|
||||||
/// while inheriting this snapshot's path and data. This should be used for
|
/// while inheriting this snapshot's path and data. This should be used for
|
||||||
/// directories.
|
/// directories.
|
||||||
@@ -43,7 +63,7 @@ impl<'sync> SyncbackSnapshot<'sync> {
|
|||||||
path: PathBuf::new(),
|
path: PathBuf::new(),
|
||||||
middleware: None,
|
middleware: None,
|
||||||
};
|
};
|
||||||
let middleware = get_best_middleware(&snapshot);
|
let middleware = get_best_middleware(&snapshot, self.data.force_json);
|
||||||
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
||||||
snapshot.path = self.path.join(name.as_ref());
|
snapshot.path = self.path.join(name.as_ref());
|
||||||
|
|
||||||
@@ -69,7 +89,7 @@ impl<'sync> SyncbackSnapshot<'sync> {
|
|||||||
path: PathBuf::new(),
|
path: PathBuf::new(),
|
||||||
middleware: None,
|
middleware: None,
|
||||||
};
|
};
|
||||||
let middleware = get_best_middleware(&snapshot);
|
let middleware = get_best_middleware(&snapshot, self.data.force_json);
|
||||||
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
||||||
snapshot.path = base_path.join(name.as_ref());
|
snapshot.path = base_path.join(name.as_ref());
|
||||||
|
|
||||||
@@ -237,6 +257,25 @@ pub fn inst_path(dom: &WeakDom, referent: Ref) -> String {
|
|||||||
path.join("/")
|
path.join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'sync> SyncbackData<'sync> {
|
||||||
|
/// Constructs a `SyncbackData` for use in unit tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn for_test(
|
||||||
|
vfs: &'sync Vfs,
|
||||||
|
old_tree: &'sync RojoTree,
|
||||||
|
new_tree: &'sync WeakDom,
|
||||||
|
project: &'sync Project,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
vfs,
|
||||||
|
old_tree,
|
||||||
|
new_tree,
|
||||||
|
project,
|
||||||
|
force_json: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
||||||
//! JSON.
|
//! JSON.
|
||||||
|
|
||||||
use std::{collections::HashMap, fs, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc};
|
use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
|
||||||
|
|
||||||
use futures::{sink::SinkExt, stream::StreamExt};
|
use futures::{sink::SinkExt, stream::StreamExt};
|
||||||
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
||||||
@@ -21,7 +21,6 @@ use crate::{
|
|||||||
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
||||||
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
||||||
},
|
},
|
||||||
origin::canonical,
|
|
||||||
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
||||||
},
|
},
|
||||||
web_api::{
|
web_api::{
|
||||||
@@ -29,12 +28,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn call(
|
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
|
||||||
serve_session: Arc<ServeSession>,
|
let service = ApiService::new(serve_session);
|
||||||
remote_addr: SocketAddr,
|
|
||||||
mut request: Request<Body>,
|
|
||||||
) -> Response<Body> {
|
|
||||||
let service = ApiService::new(serve_session, remote_addr);
|
|
||||||
|
|
||||||
match (request.method(), request.uri().path()) {
|
match (request.method(), request.uri().path()) {
|
||||||
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
|
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
|
||||||
@@ -70,15 +65,11 @@ pub async fn call(
|
|||||||
|
|
||||||
pub struct ApiService {
|
pub struct ApiService {
|
||||||
serve_session: Arc<ServeSession>,
|
serve_session: Arc<ServeSession>,
|
||||||
remote_addr: SocketAddr,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiService {
|
impl ApiService {
|
||||||
pub fn new(serve_session: Arc<ServeSession>, remote_addr: SocketAddr) -> Self {
|
pub fn new(serve_session: Arc<ServeSession>) -> Self {
|
||||||
ApiService {
|
ApiService { serve_session }
|
||||||
serve_session,
|
|
||||||
remote_addr,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a summary of information about the server
|
/// Get a summary of information about the server
|
||||||
@@ -276,7 +267,7 @@ impl ApiService {
|
|||||||
|
|
||||||
response_dom.transfer_within(child_ref, object_value);
|
response_dom.transfer_within(child_ref, object_value);
|
||||||
} else {
|
} else {
|
||||||
return msgpack(
|
msgpack(
|
||||||
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
|
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
);
|
);
|
||||||
@@ -357,30 +348,6 @@ impl ApiService {
|
|||||||
|
|
||||||
/// Open a script with the given ID in the user's default text editor.
|
/// Open a script with the given ID in the user's default text editor.
|
||||||
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
||||||
// Opening a file launches a local program, so it must never be reachable
|
|
||||||
// by a remote client even when the server is bound to an exposed address.
|
|
||||||
//
|
|
||||||
// `remote_addr` is the immediate peer, which is the best locality signal
|
|
||||||
// we have: the legitimate caller is a sandboxed Roblox plugin whose only
|
|
||||||
// credential is being able to reach the port, so there is no secret to
|
|
||||||
// authenticate it with. A connection forwarded over loopback by an
|
|
||||||
// SSH/Tailscale tunnel or a local reverse proxy therefore appears local
|
|
||||||
// and is allowed. That is delegated trust rather than a bypass: by
|
|
||||||
// standing up that tunnel or proxy the user has decided the remote end is
|
|
||||||
// trusted, and reachability is bounded by that hop's own authentication
|
|
||||||
// (e.g. SSH keys or Tailscale ACLs). This gate only stops direct,
|
|
||||||
// unauthenticated peers.
|
|
||||||
//
|
|
||||||
// An IPv4 client reaching a dual-stack (`::`) bind appears as an
|
|
||||||
// IPv4-mapped IPv6 peer (`::ffff:127.0.0.1`), so canonicalize to the bare
|
|
||||||
// IPv4 form before the loopback test, matching `origin`'s handling.
|
|
||||||
if !canonical(self.remote_addr.ip()).is_loopback() {
|
|
||||||
return msgpack(
|
|
||||||
ErrorResponse::forbidden("/api/open is only available to local clients"),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let argument = &request.uri().path()["/api/open/".len()..];
|
let argument = &request.uri().path()["/api/open/".len()..];
|
||||||
let requested_id = match Ref::from_str(argument) {
|
let requested_id = match Ref::from_str(argument) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user