use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{Arc, Mutex}, }; use rbx_dom_weak::types::Ref; use sha1::{Digest, Sha1}; use crate::{snapshot::RojoTree, web::interface::GitMetadata}; const SCRIPT_CLASSES: &[&str] = &["Script", "LocalScript", "ModuleScript"]; fn is_script_class(class: &str) -> bool { SCRIPT_CLASSES.contains(&class) } pub fn git_repo_root(project_root: &Path) -> Option { let output = Command::new("git") .args([ "-C", &project_root.to_string_lossy(), "rev-parse", "--show-toplevel", ]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); let path_str = stdout.trim(); if path_str.is_empty() { return None; } Some(PathBuf::from(path_str)) } fn git_changed_files(repo_root: &Path) -> Option> { let mut changed = HashSet::new(); let diff_output = Command::new("git") .args([ "-C", &repo_root.to_string_lossy(), "diff", "HEAD", "--name-only", ]) .output() .ok()?; if diff_output.status.success() { for line in String::from_utf8_lossy(&diff_output.stdout).lines() { let trimmed = line.trim(); if !trimmed.is_empty() { changed.insert(PathBuf::from(trimmed)); } } } else { log::debug!("git diff HEAD failed (possibly no commits yet), skipping diff"); } let untracked_output = Command::new("git") .args([ "-C", &repo_root.to_string_lossy(), "ls-files", "--others", "--exclude-standard", ]) .output() .ok()?; if untracked_output.status.success() { for line in String::from_utf8_lossy(&untracked_output.stdout).lines() { let trimmed = line.trim(); if !trimmed.is_empty() { changed.insert(PathBuf::from(trimmed)); } } } if changed.is_empty() && !diff_output.status.success() { return None; } Some(changed) } fn git_show(repo_root: &Path, object_ref: &str) -> Option { let output = Command::new("git") .args(["-C", &repo_root.to_string_lossy(), "show", object_ref]) .output() .ok()?; if !output.status.success() { return None; } String::from_utf8(output.stdout).ok() } fn git_show_head(repo_root: &Path, rel_path: &Path) -> Option { let object_ref = format!("HEAD:{}", rel_path.to_string_lossy().replace('\\', "/")); git_show(repo_root, &object_ref) } fn git_show_staged(repo_root: &Path, rel_path: &Path) -> Option { let object_ref = format!(":0:{}", rel_path.to_string_lossy().replace('\\', "/")); git_show(repo_root, &object_ref) } pub fn compute_blob_sha1(content: &str) -> String { let header = format!("blob {}\0", content.len()); let mut hasher = Sha1::new(); hasher.update(header.as_bytes()); hasher.update(content.as_bytes()); format!("{:x}", hasher.finalize()) } pub fn git_add(repo_root: &Path, paths: &[PathBuf]) { if paths.is_empty() { return; } let mut cmd = Command::new("git"); cmd.args(["-C", &repo_root.to_string_lossy(), "add", "--"]); for path in paths { cmd.arg(path); } match cmd.output() { Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::warn!("git add failed: {}", stderr.trim()); } } Err(err) => { log::warn!("Failed to run git add: {}", err); } } } struct ResolvedInstance { id: Ref, class_name: String, rel_path: PathBuf, } /// Computes git metadata for the two-way sync confirmation UI. /// /// `repo_root` must be the git repository root (from `git_repo_root()`), /// cached at session start by `ServeSession`. /// /// Uses a two-phase approach to avoid holding the tree lock during I/O: /// 1. Run git commands to get changed files (no lock) /// 2. Briefly lock tree to resolve file paths to instance Refs and class names /// 3. Run git show for each changed script and compute hashes (no lock) pub fn compute_git_metadata(tree_handle: &Arc>, repo_root: &Path) -> GitMetadata { let changed_files = match git_changed_files(repo_root) { Some(files) => files, None => { return GitMetadata { changed_ids: Vec::new(), script_committed_hashes: HashMap::new(), }; } }; if changed_files.is_empty() { return GitMetadata { changed_ids: Vec::new(), script_committed_hashes: HashMap::new(), }; } // Brief lock: resolve changed file paths to instance Refs and class names let resolved: Vec = { let tree = tree_handle.lock().unwrap(); let mut result = Vec::new(); for rel_path in &changed_files { let abs_path = repo_root.join(rel_path); let canonical_path = std::fs::canonicalize(&abs_path).ok(); let first = tree.get_ids_at_path(&abs_path); let ids = if !first.is_empty() { first } else if let Some(ref canon) = canonical_path { tree.get_ids_at_path(canon) } else { first }; for &id in ids { if let Some(instance) = tree.get_instance(id) { result.push(ResolvedInstance { id, class_name: instance.class_name().to_string(), rel_path: rel_path.clone(), }); } } } result }; // Lock released let changed_ids: Vec = resolved.iter().map(|ri| ri.id).collect(); let mut script_committed_hashes: HashMap> = HashMap::new(); for ri in &resolved { if !is_script_class(&ri.class_name) { continue; } let mut hashes = Vec::with_capacity(2); if let Some(head_content) = git_show_head(repo_root, &ri.rel_path) { hashes.push(compute_blob_sha1(&head_content)); } if let Some(staged_content) = git_show_staged(repo_root, &ri.rel_path) { let staged_hash = compute_blob_sha1(&staged_content); if hashes.is_empty() || hashes[0] != staged_hash { hashes.push(staged_hash); } } if !hashes.is_empty() { script_committed_hashes.insert(ri.id, hashes); } } GitMetadata { changed_ids, script_committed_hashes, } } /// Refreshes the git index if the project is in a git repository. /// /// This is useful because syncback may rewrite files with identical content, /// which can cause git to report them as modified due to timestamp changes. pub fn refresh_git_index(project_dir: &Path) { let mut check_dir = Some(project_dir); let mut is_git_repo = false; while let Some(dir) = check_dir { if dir.join(".git").exists() { is_git_repo = true; break; } check_dir = dir.parent(); } if is_git_repo { log::info!("Refreshing git index..."); match Command::new("git") .args(["update-index", "--refresh", "-q"]) .current_dir(project_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() { Ok(_) => log::info!("Git index refreshed."), Err(e) => log::warn!("Failed to run git update-index --refresh: {}", e), } } else { log::debug!("Not a git repository, skipping index refresh."); } } #[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); } }