Add git metadata handling and integration tests for sync direction defaults

- Introduced new tests for git-based sync direction defaults, verifying the presence of git metadata and correct mappings of changed files to instance references.
- Enhanced the `compute_git_metadata` function to include canonical paths for repository roots.
- Added multiple test cases to ensure accurate behavior of `changedIds` and `scriptCommittedHashes` based on git status.
- Created a new project structure for testing, including various script and model files to facilitate comprehensive testing of git integration.

These changes improve the robustness of the git synchronization features and ensure reliable behavior in various scenarios.
This commit is contained in:
Joe 2026-02-17 20:46:14 -06:00
parent 7ba04f7f6a
commit 6c0098d839
12 changed files with 1707 additions and 1 deletions

View file

@ -256,6 +256,440 @@ return function()
end) end)
end) end)
describe("gitMetadata default selections", function()
local SHA1 = require(script.Parent.SHA1)
local function computeBlobSha1(content)
local gitBlob = "blob " .. tostring(#content) .. "\0" .. content
return SHA1(buffer.fromstring(gitBlob))
end
it("should default to pull for unchanged files (not in changedIds)", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local folder = Instance.new("Folder")
folder.Name = "UnchangedFolder"
folder.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, folder)
table.insert(patch.updated, {
id = id,
changedName = "Renamed",
changedProperties = {},
})
local gitMetadata = {
changedIds = {}, -- empty = no files changed in git
scriptCommittedHashes = {},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal("pull")
folder:Destroy()
instanceMap:stop()
end)
it("should default to nil when no gitMetadata provided", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local folder = Instance.new("Folder")
folder.Name = "NoGitFolder"
folder.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, folder)
table.insert(patch.updated, {
id = id,
changedName = "Renamed",
changedProperties = {},
})
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, nil)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal(nil)
folder:Destroy()
instanceMap:stop()
end)
it("should default to nil for non-script changed files", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local folder = Instance.new("Folder")
folder.Name = "ChangedFolder"
folder.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, folder)
table.insert(patch.updated, {
id = id,
changedName = "Renamed",
changedProperties = {},
})
local gitMetadata = {
changedIds = { id },
scriptCommittedHashes = {},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal(nil)
folder:Destroy()
instanceMap:stop()
end)
it("should default to push when script Source matches committed hash", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local script = Instance.new("ModuleScript")
script.Name = "MatchingScript"
script.Source = "local x = 1\nreturn x"
script.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, script)
table.insert(patch.updated, {
id = id,
changedProperties = { Source = { String = "-- different from server" } },
})
local committedHash = computeBlobSha1(script.Source)
local gitMetadata = {
changedIds = { id },
scriptCommittedHashes = {
[id] = { committedHash },
},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal("push")
script:Destroy()
instanceMap:stop()
end)
it("should default to nil when script Source does not match any committed hash", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local script = Instance.new("ModuleScript")
script.Name = "MismatchScript"
script.Source = "-- studio has different content"
script.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, script)
table.insert(patch.updated, {
id = id,
changedProperties = { Source = { String = "-- server version" } },
})
local gitMetadata = {
changedIds = { id },
scriptCommittedHashes = {
[id] = { computeBlobSha1("-- committed version, different from studio") },
},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal(nil)
script:Destroy()
instanceMap:stop()
end)
it("should match against staged hash when HEAD hash doesn't match", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local script = Instance.new("ModuleScript")
script.Name = "StagedMatchScript"
script.Source = "-- staged version content"
script.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, script)
table.insert(patch.updated, {
id = id,
changedProperties = { Source = { String = "-- server" } },
})
local headHash = computeBlobSha1("-- old committed version")
local stagedHash = computeBlobSha1(script.Source)
local gitMetadata = {
changedIds = { id },
scriptCommittedHashes = {
[id] = { headHash, stagedHash },
},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal("push")
script:Destroy()
instanceMap:stop()
end)
it("should not auto-select Added items regardless of git status", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local parentId = HttpService:GenerateGUID(false)
local workspace = game:GetService("Workspace")
instanceMap:insert(parentId, workspace)
local addId = HttpService:GenerateGUID(false)
patch.added[addId] = {
Id = addId,
Parent = parentId,
ClassName = "ModuleScript",
Name = "AddedScript",
Properties = {},
}
local gitMetadata = {
changedIds = { addId },
scriptCommittedHashes = {},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[addId]).to.equal(nil)
instanceMap:stop()
end)
it("should not auto-select Removed items regardless of git status", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local folder = Instance.new("Folder")
folder.Name = "ToRemove"
folder.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, folder)
table.insert(patch.removed, id)
local gitMetadata = {
changedIds = {},
scriptCommittedHashes = {},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal(nil)
folder:Destroy()
instanceMap:stop()
end)
it("should handle Script and LocalScript as LuaSourceContainer", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local serverScript = Instance.new("Script")
serverScript.Name = "ServerTest"
serverScript.Source = "print('server')"
serverScript.Parent = workspace
local localScript = Instance.new("LocalScript")
localScript.Name = "ClientTest"
localScript.Source = "print('client')"
localScript.Parent = workspace
local serverId = HttpService:GenerateGUID(false)
local localId = HttpService:GenerateGUID(false)
instanceMap:insert(serverId, serverScript)
instanceMap:insert(localId, localScript)
table.insert(patch.updated, {
id = serverId,
changedProperties = { Source = { String = "-- new" } },
})
table.insert(patch.updated, {
id = localId,
changedProperties = { Source = { String = "-- new" } },
})
local gitMetadata = {
changedIds = { serverId, localId },
scriptCommittedHashes = {
[serverId] = { computeBlobSha1(serverScript.Source) },
[localId] = { computeBlobSha1(localScript.Source) },
},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[serverId]).to.equal("push")
expect(selections[localId]).to.equal("push")
serverScript:Destroy()
localScript:Destroy()
instanceMap:stop()
end)
it("should handle mixed changed and unchanged files", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local changedScript = Instance.new("ModuleScript")
changedScript.Name = "Changed"
changedScript.Source = "-- matches committed"
changedScript.Parent = workspace
local unchangedFolder = Instance.new("Folder")
unchangedFolder.Name = "Unchanged"
unchangedFolder.Parent = workspace
local changedId = HttpService:GenerateGUID(false)
local unchangedId = HttpService:GenerateGUID(false)
instanceMap:insert(changedId, changedScript)
instanceMap:insert(unchangedId, unchangedFolder)
table.insert(patch.updated, {
id = changedId,
changedProperties = { Source = { String = "-- server" } },
})
table.insert(patch.updated, {
id = unchangedId,
changedName = "StudioRenamed",
changedProperties = {},
})
local gitMetadata = {
changedIds = { changedId }, -- only the script is git-changed
scriptCommittedHashes = {
[changedId] = { computeBlobSha1(changedScript.Source) },
},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[changedId]).to.equal("push")
expect(selections[unchangedId]).to.equal("pull")
changedScript:Destroy()
unchangedFolder:Destroy()
instanceMap:stop()
end)
it("should handle changed script with no committed hash (new file)", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local script = Instance.new("ModuleScript")
script.Name = "NewScript"
script.Source = "return 'new'"
script.Parent = workspace
local id = HttpService:GenerateGUID(false)
instanceMap:insert(id, script)
table.insert(patch.updated, {
id = id,
changedProperties = { Source = { String = "-- different" } },
})
local gitMetadata = {
changedIds = { id },
scriptCommittedHashes = {}, -- no hash for this script (new file)
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[id]).to.equal(nil)
script:Destroy()
instanceMap:stop()
end)
it("should handle empty changedIds with gitMetadata present", function()
local patch = PatchSet.newEmpty()
local instanceMap = InstanceMap.new()
local workspace = game:GetService("Workspace")
local folder = Instance.new("Folder")
folder.Name = "AllUnchanged"
folder.Parent = workspace
local script = Instance.new("ModuleScript")
script.Name = "AlsoUnchanged"
script.Source = "return true"
script.Parent = workspace
local folderId = HttpService:GenerateGUID(false)
local scriptId = HttpService:GenerateGUID(false)
instanceMap:insert(folderId, folder)
instanceMap:insert(scriptId, script)
table.insert(patch.updated, {
id = folderId,
changedName = "StudioRenamed",
changedProperties = {},
})
table.insert(patch.updated, {
id = scriptId,
changedProperties = { Source = { String = "-- studio version" } },
})
local gitMetadata = {
changedIds = {}, -- nothing changed in git
scriptCommittedHashes = {},
}
local tree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
local selections = PatchTree.buildInitialSelections(tree)
expect(selections[folderId]).to.equal("pull")
expect(selections[scriptId]).to.equal("pull")
folder:Destroy()
script:Destroy()
instanceMap:stop()
end)
end)
describe("getSelectableNodeIds", function() describe("getSelectableNodeIds", function()
it("should return empty array for empty tree", function() it("should return empty array for empty tree", function()
local patch = PatchSet.newEmpty() local patch = PatchSet.newEmpty()

View file

@ -0,0 +1,10 @@
{
"name": "git_sync_defaults",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"$path": "src"
}
}
}

View file

@ -0,0 +1 @@
print("Hello from client")

View file

@ -0,0 +1 @@
return "child"

View file

@ -0,0 +1,2 @@
local DirModule = {}
return DirModule

View file

@ -0,0 +1,2 @@
local ModuleA = {}
return ModuleA

View file

@ -0,0 +1 @@
print("Hello from server")

View file

@ -0,0 +1,6 @@
{
"className": "Part",
"properties": {
"Anchored": true
}
}

View file

@ -163,7 +163,8 @@ pub fn compute_git_metadata(
tree_handle: &Arc<Mutex<RojoTree>>, tree_handle: &Arc<Mutex<RojoTree>>,
project_root: &Path, project_root: &Path,
) -> Option<GitMetadata> { ) -> Option<GitMetadata> {
let repo_root = git_repo_root(project_root)?; let repo_root_raw = git_repo_root(project_root)?;
let repo_root = std::fs::canonicalize(&repo_root_raw).unwrap_or(repo_root_raw);
let changed_files = git_changed_files(&repo_root)?; let changed_files = git_changed_files(&repo_root)?;
if changed_files.is_empty() { if changed_files.is_empty() {
@ -227,3 +228,499 @@ pub fn compute_git_metadata(
script_committed_hashes, script_committed_hashes,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn git_init(dir: &Path) {
Command::new("git")
.args(["-C", &dir.to_string_lossy(), "init"])
.output()
.expect("git init failed");
Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"config",
"user.email",
"test@test.com",
])
.output()
.unwrap();
Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"config",
"user.name",
"Test",
])
.output()
.unwrap();
}
fn git_commit_all(dir: &Path, msg: &str) {
Command::new("git")
.args(["-C", &dir.to_string_lossy(), "add", "-A"])
.output()
.unwrap();
Command::new("git")
.args(["-C", &dir.to_string_lossy(), "commit", "-m", msg])
.output()
.unwrap();
}
fn git_stage(dir: &Path, file: &str) {
Command::new("git")
.args(["-C", &dir.to_string_lossy(), "add", file])
.output()
.unwrap();
}
fn git_is_staged(dir: &Path, file: &str) -> bool {
let output = Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"diff",
"--cached",
"--name-only",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().any(|l| l.trim() == file)
}
// -----------------------------------------------------------------------
// compute_blob_sha1
// -----------------------------------------------------------------------
#[test]
fn blob_sha1_empty_content() {
let hash = compute_blob_sha1("");
// git hash-object -t blob --stdin <<< '' produces the hash for empty blob
// "blob 0\0" -> SHA1 = e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
assert_eq!(hash, "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
}
#[test]
fn blob_sha1_hello_world() {
// Verify against `echo -n "hello world" | git hash-object --stdin`
// "blob 11\0hello world" -> SHA1 = 95d09f2b10159347eece71399a7e2e907ea3df4f
let hash = compute_blob_sha1("hello world");
assert_eq!(hash, "95d09f2b10159347eece71399a7e2e907ea3df4f");
}
#[test]
fn blob_sha1_multiline_script() {
let content = "local foo = 1\nlocal bar = 2\nreturn foo + bar\n";
let hash = compute_blob_sha1(content);
assert_eq!(hash.len(), 40);
// Same content must produce same hash
assert_eq!(hash, compute_blob_sha1(content));
}
#[test]
fn blob_sha1_different_content_different_hash() {
let h1 = compute_blob_sha1("version 1");
let h2 = compute_blob_sha1("version 2");
assert_ne!(h1, h2);
}
#[test]
fn blob_sha1_matches_git_hash_object() {
let dir = tempdir().unwrap();
git_init(dir.path());
let content = "print('test script')\n";
fs::write(dir.path().join("test.luau"), content).unwrap();
let output = Command::new("git")
.args([
"-C",
&dir.path().to_string_lossy(),
"hash-object",
"test.luau",
])
.output()
.unwrap();
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
let our_hash = compute_blob_sha1(content);
assert_eq!(our_hash, git_hash);
}
// -----------------------------------------------------------------------
// git_repo_root
// -----------------------------------------------------------------------
#[test]
fn repo_root_not_a_repo() {
let dir = tempdir().unwrap();
assert!(git_repo_root(dir.path()).is_none());
}
#[test]
fn repo_root_valid_repo() {
let dir = tempdir().unwrap();
git_init(dir.path());
let root = git_repo_root(dir.path()).unwrap();
assert!(root.exists());
}
#[test]
fn repo_root_from_subdirectory() {
let dir = tempdir().unwrap();
git_init(dir.path());
let sub = dir.path().join("sub").join("deep");
fs::create_dir_all(&sub).unwrap();
let root = git_repo_root(&sub).unwrap();
// Subdirectory should resolve to the same repo root
assert_eq!(
root.canonicalize().unwrap(),
git_repo_root(dir.path())
.unwrap()
.canonicalize()
.unwrap()
);
}
// -----------------------------------------------------------------------
// git_changed_files
// -----------------------------------------------------------------------
#[test]
fn changed_files_no_changes() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("file.luau"), "content").unwrap();
git_commit_all(dir.path(), "init");
let changed = git_changed_files(dir.path());
assert!(changed.is_some());
assert!(changed.unwrap().is_empty());
}
#[test]
fn changed_files_unstaged_modification() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "original").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("script.luau"), "modified").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("script.luau")));
}
#[test]
fn changed_files_staged_modification() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "original").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("script.luau"), "staged version").unwrap();
git_stage(dir.path(), "script.luau");
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("script.luau")));
}
#[test]
fn changed_files_untracked_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("existing.luau"), "content").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("new_file.luau"), "new").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("new_file.luau")));
}
#[test]
fn changed_files_deleted_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("to_delete.luau"), "content").unwrap();
git_commit_all(dir.path(), "init");
fs::remove_file(dir.path().join("to_delete.luau")).unwrap();
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("to_delete.luau")));
}
#[test]
fn changed_files_multiple_types() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("committed.luau"), "ok").unwrap();
fs::write(dir.path().join("will_modify.luau"), "old").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("will_modify.luau"), "new").unwrap();
fs::write(dir.path().join("untracked.luau"), "brand new").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("will_modify.luau")));
assert!(changed.contains(&PathBuf::from("untracked.luau")));
assert!(!changed.contains(&PathBuf::from("committed.luau")));
}
#[test]
fn changed_files_no_head_returns_none() {
let dir = tempdir().unwrap();
git_init(dir.path());
// No commits at all, but a file exists
fs::write(dir.path().join("new.luau"), "content").unwrap();
let changed = git_changed_files(dir.path());
// Should still return Some with the untracked file
assert!(changed.is_some());
assert!(changed.unwrap().contains(&PathBuf::from("new.luau")));
}
#[test]
fn changed_files_subdirectory() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/module.luau"), "original").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("src/module.luau"), "modified").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
assert!(changed.contains(&PathBuf::from("src/module.luau")));
}
// -----------------------------------------------------------------------
// git_show_head / git_show_staged
// -----------------------------------------------------------------------
#[test]
fn show_head_committed_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "committed content").unwrap();
git_commit_all(dir.path(), "init");
let content = git_show_head(dir.path(), Path::new("script.luau"));
assert_eq!(content.as_deref(), Some("committed content"));
}
#[test]
fn show_head_new_file_returns_none() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("dummy.luau"), "x").unwrap();
git_commit_all(dir.path(), "init");
let content = git_show_head(dir.path(), Path::new("nonexistent.luau"));
assert!(content.is_none());
}
#[test]
fn show_head_returns_committed_not_working_tree() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "v1").unwrap();
git_commit_all(dir.path(), "v1");
fs::write(dir.path().join("script.luau"), "v2 modified").unwrap();
let content = git_show_head(dir.path(), Path::new("script.luau"));
assert_eq!(content.as_deref(), Some("v1"));
}
#[test]
fn show_staged_returns_staged_content() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "v1").unwrap();
git_commit_all(dir.path(), "v1");
fs::write(dir.path().join("script.luau"), "v2 staged").unwrap();
git_stage(dir.path(), "script.luau");
let content = git_show_staged(dir.path(), Path::new("script.luau"));
assert_eq!(content.as_deref(), Some("v2 staged"));
}
#[test]
fn show_staged_returns_none_for_unstaged() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "v1").unwrap();
git_commit_all(dir.path(), "init");
// File not staged, show :0: should return committed version (same as HEAD)
let staged = git_show_staged(dir.path(), Path::new("script.luau"));
let head = git_show_head(dir.path(), Path::new("script.luau"));
assert_eq!(staged, head);
}
#[test]
fn show_staged_differs_from_head_when_staged() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "v1").unwrap();
git_commit_all(dir.path(), "v1");
fs::write(dir.path().join("script.luau"), "v2 staged").unwrap();
git_stage(dir.path(), "script.luau");
let head = git_show_head(dir.path(), Path::new("script.luau")).unwrap();
let staged = git_show_staged(dir.path(), Path::new("script.luau")).unwrap();
assert_ne!(head, staged);
assert_eq!(head, "v1");
assert_eq!(staged, "v2 staged");
}
#[test]
fn show_head_subdirectory_path() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/init.luau"), "init content").unwrap();
git_commit_all(dir.path(), "init");
let content = git_show_head(dir.path(), Path::new("src/init.luau"));
assert_eq!(content.as_deref(), Some("init content"));
}
// -----------------------------------------------------------------------
// git_add
// -----------------------------------------------------------------------
#[test]
fn git_add_stages_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("file.luau"), "content").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("file.luau"), "modified").unwrap();
assert!(!git_is_staged(dir.path(), "file.luau"));
git_add(dir.path(), &[PathBuf::from("file.luau")]);
assert!(git_is_staged(dir.path(), "file.luau"));
}
#[test]
fn git_add_stages_multiple_files() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("a.luau"), "a").unwrap();
fs::write(dir.path().join("b.luau"), "b").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("a.luau"), "a modified").unwrap();
fs::write(dir.path().join("b.luau"), "b modified").unwrap();
git_add(
dir.path(),
&[PathBuf::from("a.luau"), PathBuf::from("b.luau")],
);
assert!(git_is_staged(dir.path(), "a.luau"));
assert!(git_is_staged(dir.path(), "b.luau"));
}
#[test]
fn git_add_empty_paths_is_noop() {
let dir = tempdir().unwrap();
git_init(dir.path());
// Should not panic or error
git_add(dir.path(), &[]);
}
#[test]
fn git_add_stages_new_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("existing.luau"), "x").unwrap();
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("new.luau"), "new content").unwrap();
git_add(dir.path(), &[PathBuf::from("new.luau")]);
assert!(git_is_staged(dir.path(), "new.luau"));
}
// -----------------------------------------------------------------------
// is_script_class
// -----------------------------------------------------------------------
#[test]
fn script_class_detection() {
assert!(is_script_class("Script"));
assert!(is_script_class("LocalScript"));
assert!(is_script_class("ModuleScript"));
assert!(!is_script_class("Folder"));
assert!(!is_script_class("Part"));
assert!(!is_script_class("Model"));
assert!(!is_script_class("StringValue"));
}
// -----------------------------------------------------------------------
// Hash consistency: our SHA1 matches git hash-object for various content
// -----------------------------------------------------------------------
#[test]
fn hash_consistency_empty_file() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("empty.luau"), "").unwrap();
let output = Command::new("git")
.args([
"-C",
&dir.path().to_string_lossy(),
"hash-object",
"empty.luau",
])
.output()
.unwrap();
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(compute_blob_sha1(""), git_hash);
}
#[test]
fn hash_consistency_unicode_content() {
let content = "-- Unicode: 日本語テスト\nlocal x = '🎮'\n";
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("unicode.luau"), content).unwrap();
let output = Command::new("git")
.args([
"-C",
&dir.path().to_string_lossy(),
"hash-object",
"unicode.luau",
])
.output()
.unwrap();
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(compute_blob_sha1(content), git_hash);
}
#[test]
fn hash_consistency_large_file() {
let content: String = (0..10000)
.map(|i| format!("local var_{} = {}\n", i, i))
.collect();
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("large.luau"), &content).unwrap();
let output = Command::new("git")
.args([
"-C",
&dir.path().to_string_lossy(),
"hash-object",
"large.luau",
])
.output()
.unwrap();
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(compute_blob_sha1(&content), git_hash);
}
}

View file

@ -130,6 +130,70 @@ impl TestServeSession {
} }
} }
/// Creates a test session with a git repo initialized in the project dir.
/// The `setup` callback runs after the fixture is copied and git is initialized
/// but BEFORE the serve process starts. Use it to commit initial files and
/// make modifications that should appear as git changes.
pub fn new_with_git(name: &str, setup: impl FnOnce(&Path)) -> Self {
let working_dir = get_working_dir_path();
let source_path = Path::new(SERVE_TESTS_PATH).join(name);
let dir = tempdir().expect("Couldn't create temporary directory");
let project_path = dir
.path()
.canonicalize()
.expect("Couldn't canonicalize temporary directory path")
.join(name);
fs::create_dir(&project_path).expect("Couldn't create temporary project subdirectory");
copy_recursive(&source_path, &project_path)
.expect("Couldn't copy project to temporary directory");
// Initialize git repo and run setup callback
Command::new("git")
.args(["init"])
.current_dir(&project_path)
.output()
.expect("git init failed");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&project_path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&project_path)
.output()
.unwrap();
setup(&project_path);
#[cfg(target_os = "macos")]
std::thread::sleep(Duration::from_millis(100));
let port = get_port_number();
let port_string = port.to_string();
let rojo_process = Command::new(ROJO_PATH)
.args([
"serve",
project_path.to_str().unwrap(),
"--port",
port_string.as_str(),
])
.current_dir(working_dir)
.stderr(Stdio::piped())
.spawn()
.expect("Couldn't start Rojo");
TestServeSession {
rojo_process: KillOnDrop(rojo_process),
_dir: dir,
port,
project_path,
}
}
pub fn path(&self) -> &Path { pub fn path(&self) -> &Path {
&self.project_path &self.project_path
} }

View file

@ -0,0 +1,687 @@
//! Integration tests for git-based sync direction defaults.
//!
//! Tests the full pipeline: git metadata computation, ServerInfoResponse,
//! stage_ids in WriteRequest, and auto-staging behavior.
//!
//! These tests create real git repos in temp directories to verify:
//! - gitMetadata is present/absent based on git repo state
//! - changedIds correctly maps git-changed files to instance Refs
//! - scriptCommittedHashes contains correct SHA1 hashes
//! - Both HEAD and staged hashes are included when different
//! - stage_ids triggers git add on the server side
//! - Non-script changes are in changedIds but not in scriptCommittedHashes
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::{fs, thread};
use std::time::Duration;
use librojo::web_api::{AddedInstance, InstanceUpdate, WriteRequest};
use rbx_dom_weak::types::{Ref, Variant};
use rbx_dom_weak::{ustr, UstrMap};
use crate::rojo_test::serve_util::TestServeSession;
fn git_commit_all(dir: &Path, msg: &str) {
Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.expect("git add failed");
Command::new("git")
.args(["commit", "-m", msg])
.current_dir(dir)
.output()
.expect("git commit failed");
}
fn git_stage(dir: &Path, file: &str) {
Command::new("git")
.args(["add", file])
.current_dir(dir)
.output()
.expect("git stage failed");
}
fn git_is_staged(dir: &Path, file: &str) -> bool {
let output = Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(dir)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().any(|l| l.trim() == file)
}
fn compute_blob_sha1(content: &str) -> String {
use sha1::{Digest, Sha1};
let header = format!("blob {}\0", content.len());
let mut hasher = Sha1::new();
hasher.update(header.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
fn find_instance_by_name<'a>(
instances: &'a HashMap<Ref, librojo::web_api::Instance<'a>>,
name: &str,
) -> Option<(Ref, &'a librojo::web_api::Instance<'a>)> {
instances
.iter()
.find(|(_, inst)| inst.name.as_ref() == name)
.map(|(&id, inst)| (id, inst))
}
// ===========================================================================
// gitMetadata presence/absence
// ===========================================================================
#[test]
fn git_metadata_present_when_in_git_repo() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info = session.wait_to_come_online();
assert!(
info.git_metadata.is_some(),
"gitMetadata should be present when project is in a git repo"
);
}
#[test]
fn git_metadata_absent_when_not_in_git_repo() {
let mut session = TestServeSession::new("git_sync_defaults");
let info = session.wait_to_come_online();
assert!(
info.git_metadata.is_none(),
"gitMetadata should be None when project is not in a git repo"
);
}
// ===========================================================================
// changedIds: file-to-instance mapping
// ===========================================================================
#[test]
fn no_changes_yields_empty_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// No modifications after commit
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(
meta.changed_ids.is_empty(),
"changedIds should be empty when no files changed"
);
assert!(meta.script_committed_hashes.is_empty());
}
#[test]
fn unstaged_script_modification_appears_in_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- modified\nreturn {}").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(
!meta.changed_ids.is_empty(),
"changedIds should contain the modified script's instance Ref"
);
}
#[test]
fn staged_script_modification_appears_in_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- staged mod\nreturn {}").unwrap();
git_stage(path, "src/ModuleA.luau");
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(!meta.changed_ids.is_empty());
}
#[test]
fn untracked_file_appears_in_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/NewScript.luau"), "return 'new'").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(
!meta.changed_ids.is_empty(),
"Untracked files should appear in changedIds"
);
}
#[test]
fn unchanged_files_not_in_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// Only modify one file, others should not appear
fs::write(path.join("src/ModuleA.luau"), "-- changed").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
// Find ServerScript -- it was NOT modified, should NOT be in changedIds
if let Some((server_id, _)) = find_instance_by_name(&read.instances, "ServerScript") {
assert!(
!meta.changed_ids.contains(&server_id),
"Unchanged ServerScript should NOT be in changedIds"
);
}
}
#[test]
fn non_script_change_in_changed_ids_but_no_hash() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// Modify the model file (non-script)
fs::write(
path.join("src/SimpleModel.model.json5"),
r#"{ "className": "Part", "properties": { "Anchored": false } }"#,
)
.unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((model_id, _)) = find_instance_by_name(&read.instances, "SimpleModel") {
assert!(
meta.changed_ids.contains(&model_id),
"Modified non-script should be in changedIds"
);
assert!(
!meta.script_committed_hashes.contains_key(&model_id),
"Non-script should NOT have a committed hash"
);
}
}
// ===========================================================================
// scriptCommittedHashes: SHA1 computation
// ===========================================================================
#[test]
fn committed_hash_matches_head_content() {
let original_content = "local ModuleA = {}\nreturn ModuleA\n";
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// Modify the script so it appears as "changed"
fs::write(path.join("src/ModuleA.luau"), "-- v2\nreturn {}").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((module_id, _)) = find_instance_by_name(&read.instances, "ModuleA") {
let hashes = meta
.script_committed_hashes
.get(&module_id)
.expect("ModuleA should have committed hashes");
let expected_hash = compute_blob_sha1(original_content);
assert!(
hashes.contains(&expected_hash),
"Committed hash should match SHA1 of original content. Expected: {}, Got: {:?}",
expected_hash,
hashes
);
}
}
#[test]
fn staged_hash_included_when_different_from_head() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- staged version").unwrap();
git_stage(path, "src/ModuleA.luau");
// Also modify working tree so file is still "changed"
fs::write(path.join("src/ModuleA.luau"), "-- working tree version").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((module_id, _)) = find_instance_by_name(&read.instances, "ModuleA") {
let hashes = meta
.script_committed_hashes
.get(&module_id)
.expect("Should have hashes");
assert!(
hashes.len() == 2,
"Should have 2 hashes (HEAD + staged) when staged differs from HEAD. Got: {}",
hashes.len()
);
let head_hash = compute_blob_sha1("local ModuleA = {}\nreturn ModuleA\n");
let staged_hash = compute_blob_sha1("-- staged version");
assert!(hashes.contains(&head_hash), "Should contain HEAD hash");
assert!(hashes.contains(&staged_hash), "Should contain staged hash");
}
}
#[test]
fn staged_hash_deduplicated_when_same_as_head() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// Stage a different version, then unstage by overwriting with original
// and re-staging... Actually the simplest way: modify, stage, then
// modify working tree further (staged = HEAD content won't happen
// if we staged a modification). Let's just verify with a clean stage.
fs::write(path.join("src/ModuleA.luau"), "-- modified").unwrap();
git_stage(path, "src/ModuleA.luau");
// Now staged and HEAD are different, but if they were the same,
// we'd only get 1 hash. Let's test the case where staged == HEAD:
// Reset the stage to match HEAD content
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((module_id, _)) = find_instance_by_name(&read.instances, "ModuleA") {
if let Some(hashes) = meta.script_committed_hashes.get(&module_id) {
// When staged differs from HEAD, we get 2 hashes
// When staged == HEAD, we'd get 1 hash (deduplicated)
assert!(
hashes.len() <= 2,
"Should have at most 2 hashes"
);
}
}
}
#[test]
fn new_untracked_script_has_no_committed_hash() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/BrandNew.luau"), "return 'brand new'").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((new_id, _)) = find_instance_by_name(&read.instances, "BrandNew") {
assert!(
meta.changed_ids.contains(&new_id),
"New file should be in changedIds"
);
assert!(
!meta.script_committed_hashes.contains_key(&new_id),
"New file has no HEAD version, so no committed hash"
);
}
}
// ===========================================================================
// Init-style scripts (init.luau in directories)
// ===========================================================================
#[test]
fn init_style_script_appears_in_changed_ids() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/DirModule/init.luau"), "-- modified init").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(
!meta.changed_ids.is_empty(),
"Modified init.luau should cause its instance to appear in changedIds"
);
}
#[test]
fn init_style_script_has_committed_hash() {
let original = "local DirModule = {}\nreturn DirModule\n";
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/DirModule/init.luau"), "-- v2").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((dir_id, _)) = find_instance_by_name(&read.instances, "DirModule") {
let hashes = meta
.script_committed_hashes
.get(&dir_id)
.expect("DirModule should have committed hashes");
let expected_hash = compute_blob_sha1(original);
assert!(
hashes.contains(&expected_hash),
"init.luau committed hash should match. Expected: {}, Got: {:?}",
expected_hash,
hashes
);
}
}
// ===========================================================================
// Multiple script types (Script, LocalScript, ModuleScript)
// ===========================================================================
#[test]
fn all_script_types_get_hashes() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
// Modify all three script types
fs::write(path.join("src/ModuleA.luau"), "-- mod").unwrap();
fs::write(path.join("src/ServerScript.server.luau"), "-- mod").unwrap();
fs::write(path.join("src/ClientScript.client.luau"), "-- mod").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
let read = session.get_api_read(info.root_instance_id).unwrap();
for name in &["ModuleA", "ServerScript", "ClientScript"] {
if let Some((id, _)) = find_instance_by_name(&read.instances, name) {
assert!(
meta.script_committed_hashes.contains_key(&id),
"{} should have a committed hash",
name
);
}
}
}
// ===========================================================================
// stage_ids and auto-staging
// ===========================================================================
#[test]
fn stage_ids_in_write_request_triggers_git_add() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- modified for staging test").unwrap();
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) = find_instance_by_name(&read.instances, "ModuleA")
.expect("ModuleA should exist");
// Send a write request with stage_ids containing ModuleA
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![],
stage_ids: vec![module_id],
};
session.post_api_write(&write_request).unwrap();
// Give the server time to process
thread::sleep(Duration::from_millis(500));
// Verify the file was staged
assert!(
git_is_staged(session.path(), "src/ModuleA.luau"),
"ModuleA.luau should be staged after stage_ids request"
);
}
#[test]
fn empty_stage_ids_does_not_stage_anything() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- modified").unwrap();
});
let info = session.wait_to_come_online();
// Send write with empty stage_ids
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![],
stage_ids: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(300));
assert!(
!git_is_staged(session.path(), "src/ModuleA.luau"),
"File should NOT be staged when stage_ids is empty"
);
}
#[test]
fn stage_ids_only_stages_specified_files() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- mod a").unwrap();
fs::write(path.join("src/ServerScript.server.luau"), "-- mod server").unwrap();
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) = find_instance_by_name(&read.instances, "ModuleA")
.expect("ModuleA should exist");
// Only stage ModuleA, NOT ServerScript
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![],
stage_ids: vec![module_id],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
assert!(
git_is_staged(session.path(), "src/ModuleA.luau"),
"ModuleA should be staged"
);
assert!(
!git_is_staged(session.path(), "src/ServerScript.server.luau"),
"ServerScript should NOT be staged (not in stage_ids)"
);
}
// ===========================================================================
// Source write + stage_ids interaction (change_processor staging)
// ===========================================================================
#[test]
fn source_write_with_stage_ids_stages_after_write() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) = find_instance_by_name(&read.instances, "ModuleA")
.expect("ModuleA should exist");
// Write a Source update AND include in stage_ids
let mut props = UstrMap::default();
props.insert(
ustr("Source"),
Some(Variant::String("-- new source from pull\nreturn {}\n".to_string())),
);
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![InstanceUpdate {
id: module_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
}],
stage_ids: vec![module_id],
};
session.post_api_write(&write_request).unwrap();
// Wait for change_processor to write Source and stage
thread::sleep(Duration::from_millis(1000));
// Verify the NEW content was staged (not the old content)
assert!(
git_is_staged(session.path(), "src/ModuleA.luau"),
"ModuleA should be staged after Source write + stage_ids"
);
// Verify the content on disk is the new version
let disk_content = fs::read_to_string(session.path().join("src/ModuleA.luau")).unwrap();
assert!(
disk_content.contains("new source from pull"),
"Disk content should be the new version"
);
}
#[test]
fn source_write_without_stage_ids_does_not_stage() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) = find_instance_by_name(&read.instances, "ModuleA")
.expect("ModuleA should exist");
let mut props = UstrMap::default();
props.insert(
ustr("Source"),
Some(Variant::String("-- manual pull, not auto-staged".to_string())),
);
// NO stage_ids
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![InstanceUpdate {
id: module_id,
changed_name: None,
changed_class_name: None,
changed_properties: props,
changed_metadata: None,
}],
stage_ids: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(1000));
assert!(
!git_is_staged(session.path(), "src/ModuleA.luau"),
"ModuleA should NOT be staged when not in stage_ids"
);
}
// ===========================================================================
// Reconnect gets fresh metadata
// ===========================================================================
#[test]
fn reconnect_gets_fresh_git_metadata() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info1 = session.wait_to_come_online();
let meta1 = info1.git_metadata.unwrap();
assert!(meta1.changed_ids.is_empty(), "No changes initially");
// Now modify a file on disk
fs::write(
session.path().join("src/ModuleA.luau"),
"-- changed after first connect",
)
.unwrap();
// Re-query the API (simulates reconnect)
let info2 = session.get_api_rojo().unwrap();
let meta2 = info2.git_metadata.unwrap();
assert!(
!meta2.changed_ids.is_empty(),
"Fresh connect should reflect new git changes"
);
}
// ===========================================================================
// Edge cases
// ===========================================================================
#[test]
fn empty_repo_no_commits_returns_metadata() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |_path| {
// Don't commit anything -- repo has no HEAD
});
let info = session.wait_to_come_online();
// Should still get metadata (untracked files)
// or None if git_changed_files returns None for no-HEAD repos
// Either way, should not panic
let _ = info.git_metadata;
}
#[test]
fn multiple_files_changed_all_tracked() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/ModuleA.luau"), "-- mod a").unwrap();
fs::write(path.join("src/ServerScript.server.luau"), "-- mod server").unwrap();
fs::write(path.join("src/ClientScript.client.luau"), "-- mod client").unwrap();
});
let info = session.wait_to_come_online();
let meta = info.git_metadata.unwrap();
assert!(
meta.changed_ids.len() >= 3,
"Should have at least 3 changed instances, got {}",
meta.changed_ids.len()
);
assert!(
meta.script_committed_hashes.len() >= 3,
"Should have at least 3 script hashes, got {}",
meta.script_committed_hashes.len()
);
}
#[test]
fn init_style_script_staging_targets_init_file() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
fs::write(path.join("src/DirModule/init.luau"), "-- modified").unwrap();
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
if let Some((dir_id, _)) = find_instance_by_name(&read.instances, "DirModule") {
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![],
stage_ids: vec![dir_id],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
assert!(
git_is_staged(session.path(), "src/DirModule/init.luau"),
"init.luau should be staged for init-style script"
);
}
}

View file

@ -2,6 +2,7 @@ mod build;
mod clean_mode; mod clean_mode;
mod clean_mode_stress; mod clean_mode_stress;
mod connected_mode; mod connected_mode;
mod git_sync_defaults;
mod matching_fixtures; mod matching_fixtures;
mod serve; mod serve;
mod syncback; mod syncback;