diff --git a/plugin/src/PatchTree.spec.lua b/plugin/src/PatchTree.spec.lua index 46d13ef0..88f066b0 100644 --- a/plugin/src/PatchTree.spec.lua +++ b/plugin/src/PatchTree.spec.lua @@ -256,6 +256,440 @@ return function() 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() it("should return empty array for empty tree", function() local patch = PatchSet.newEmpty() diff --git a/rojo-test/serve-tests/git_sync_defaults/default.project.json5 b/rojo-test/serve-tests/git_sync_defaults/default.project.json5 new file mode 100644 index 00000000..398f24b8 --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/default.project.json5 @@ -0,0 +1,10 @@ +{ + "name": "git_sync_defaults", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "src" + } + } +} diff --git a/rojo-test/serve-tests/git_sync_defaults/src/ClientScript.client.luau b/rojo-test/serve-tests/git_sync_defaults/src/ClientScript.client.luau new file mode 100644 index 00000000..2e9ef4dc --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/ClientScript.client.luau @@ -0,0 +1 @@ +print("Hello from client") diff --git a/rojo-test/serve-tests/git_sync_defaults/src/DirModule/Child.luau b/rojo-test/serve-tests/git_sync_defaults/src/DirModule/Child.luau new file mode 100644 index 00000000..904b8885 --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/DirModule/Child.luau @@ -0,0 +1 @@ +return "child" diff --git a/rojo-test/serve-tests/git_sync_defaults/src/DirModule/init.luau b/rojo-test/serve-tests/git_sync_defaults/src/DirModule/init.luau new file mode 100644 index 00000000..650e627b --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/DirModule/init.luau @@ -0,0 +1,2 @@ +local DirModule = {} +return DirModule diff --git a/rojo-test/serve-tests/git_sync_defaults/src/ModuleA.luau b/rojo-test/serve-tests/git_sync_defaults/src/ModuleA.luau new file mode 100644 index 00000000..92915fc7 --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/ModuleA.luau @@ -0,0 +1,2 @@ +local ModuleA = {} +return ModuleA diff --git a/rojo-test/serve-tests/git_sync_defaults/src/ServerScript.server.luau b/rojo-test/serve-tests/git_sync_defaults/src/ServerScript.server.luau new file mode 100644 index 00000000..5b362e07 --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/ServerScript.server.luau @@ -0,0 +1 @@ +print("Hello from server") diff --git a/rojo-test/serve-tests/git_sync_defaults/src/SimpleModel.model.json5 b/rojo-test/serve-tests/git_sync_defaults/src/SimpleModel.model.json5 new file mode 100644 index 00000000..063296c9 --- /dev/null +++ b/rojo-test/serve-tests/git_sync_defaults/src/SimpleModel.model.json5 @@ -0,0 +1,6 @@ +{ + "className": "Part", + "properties": { + "Anchored": true + } +} diff --git a/src/git.rs b/src/git.rs index 0a25650f..e39af892 100644 --- a/src/git.rs +++ b/src/git.rs @@ -163,7 +163,8 @@ pub fn compute_git_metadata( tree_handle: &Arc>, project_root: &Path, ) -> Option { - 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)?; if changed_files.is_empty() { @@ -227,3 +228,499 @@ pub fn compute_git_metadata( 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); + } +} diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index 5d7773b0..ce56845b 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -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 { &self.project_path } diff --git a/tests/tests/git_sync_defaults.rs b/tests/tests/git_sync_defaults.rs new file mode 100644 index 00000000..4405eef1 --- /dev/null +++ b/tests/tests/git_sync_defaults.rs @@ -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>, + 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" + ); + } +} diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs index a261d462..e78b98f4 100644 --- a/tests/tests/mod.rs +++ b/tests/tests/mod.rs @@ -2,6 +2,7 @@ mod build; mod clean_mode; mod clean_mode_stress; mod connected_mode; +mod git_sync_defaults; mod matching_fixtures; mod serve; mod syncback;