Enhance git integration and optimize change detection in ServeSession

- Introduced a new function to retrieve the current HEAD commit SHA, allowing for better tracking of changes in the git repository.
- Updated the `git_changed_files` function to accept an initial HEAD parameter, improving the accuracy of change detection.
- Modified `ServeSession` to store the initial HEAD commit SHA at session start, ensuring committed changes are reflected in the auto-selection logic.
- Enhanced tests to verify that committed changes after session start are correctly identified and included in the change tracking.

These changes aim to improve the synchronization process with git and enhance the user experience by ensuring accurate change detection.
This commit is contained in:
Joe 2026-03-01 06:59:22 -06:00
parent 7d08b8990f
commit cd87c63a89
5 changed files with 219 additions and 29 deletions

View file

@ -329,17 +329,16 @@ Worst case - we reconcile only on connect, but do so in mega-parallel
Also, we need to explore how VSCode deals with this. They seem to have ZERO issues.
Maybe we can have a mode to tie into the vscode file watcher ourselves? luau-lsp does this.
[auto select issue]
when you commit changes on git, it's considered unchanged. we need send prior git hash too, like prior to head.
right now i believe we are calculating the hashes manually, we CAN use the git hash since our hash schema matches it 1:1.
this would be a nice optimization, too.
[syncback]
detect when the output file hash is the same, and dont write to it.
we can utilize the git hashes we also use for auto select, too. we use the same format for hashes as git, too - so maybe we could do something optimized here if git is being used.
because have already loaded it, we can detect this.
this is an optimization to avoid writing 100,000 files or something on larger projects when file bytes match perfectly, or directories verifiably already exist.
make sure this is applied to plugin-based syncback, too.
[git]
I notice we actually use the git cli in a lot of places. is there a git library we can use instead?
[auto select studio/atlas]
when things get added AFTER the fact because we send the git history hashes on connect rather than continiously, it auto-selects Studio for things that are being edited in real-time in the sync dialog
how should we handle new files? because it could have been that someone just deleted it in studio. we can actually check if its a truly new file in Studio, like A but not staged!

View file

@ -39,15 +39,36 @@ pub fn git_repo_root(project_root: &Path) -> Option<PathBuf> {
Some(PathBuf::from(path_str))
}
fn git_changed_files(repo_root: &Path) -> Option<HashSet<PathBuf>> {
/// Returns the current HEAD commit SHA, or `None` if there are no commits
/// or the project is not in a git repo.
pub fn git_head_commit(repo_root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["-C", &repo_root.to_string_lossy(), "rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
Some(sha)
} else {
None
}
}
fn git_changed_files(repo_root: &Path, initial_head: Option<&str>) -> Option<HashSet<PathBuf>> {
let mut changed = HashSet::new();
let diff_base = initial_head.unwrap_or("HEAD");
let diff_output = Command::new("git")
.args([
"-C",
&repo_root.to_string_lossy(),
"diff",
"HEAD",
diff_base,
"--name-only",
])
.output()
@ -61,7 +82,10 @@ fn git_changed_files(repo_root: &Path) -> Option<HashSet<PathBuf>> {
}
}
} else {
log::debug!("git diff HEAD failed (possibly no commits yet), skipping diff");
log::debug!(
"git diff {} failed (possibly no commits yet), skipping diff",
diff_base
);
}
let untracked_output = Command::new("git")
@ -243,8 +267,12 @@ struct ResolvedInstance {
/// 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. Single `git cat-file --batch-check` call to get blob hashes (no lock)
pub fn compute_git_metadata(tree_handle: &Arc<Mutex<RojoTree>>, repo_root: &Path) -> GitMetadata {
let changed_files = match git_changed_files(repo_root) {
pub fn compute_git_metadata(
tree_handle: &Arc<Mutex<RojoTree>>,
repo_root: &Path,
initial_head: Option<&str>,
) -> GitMetadata {
let changed_files = match git_changed_files(repo_root, initial_head) {
Some(files) => files,
None => {
return GitMetadata {
@ -296,9 +324,16 @@ pub fn compute_git_metadata(tree_handle: &Arc<Mutex<RojoTree>>, repo_root: &Path
let changed_ids: Vec<Ref> = resolved.iter().map(|ri| ri.id).collect();
let mut script_committed_hashes: HashMap<Ref, Vec<String>> = HashMap::new();
#[derive(Clone, Copy)]
enum RefKind {
Head,
Staged,
InitialHead,
}
struct ScriptRef {
resolved_idx: usize,
is_staged: bool,
kind: RefKind,
}
let mut object_refs = Vec::new();
@ -313,14 +348,22 @@ pub fn compute_git_metadata(tree_handle: &Arc<Mutex<RojoTree>>, repo_root: &Path
object_refs.push(format!("HEAD:{}", path_str));
script_refs.push(ScriptRef {
resolved_idx: idx,
is_staged: false,
kind: RefKind::Head,
});
object_refs.push(format!(":0:{}", path_str));
script_refs.push(ScriptRef {
resolved_idx: idx,
is_staged: true,
kind: RefKind::Staged,
});
if let Some(ih) = initial_head {
object_refs.push(format!("{}:{}", ih, path_str));
script_refs.push(ScriptRef {
resolved_idx: idx,
kind: RefKind::InitialHead,
});
}
}
let batch_hashes = git_batch_check_hashes(repo_root, &object_refs);
@ -333,16 +376,19 @@ pub fn compute_git_metadata(tree_handle: &Arc<Mutex<RojoTree>>, repo_root: &Path
let ri = &resolved[info.resolved_idx];
let hashes = script_committed_hashes
.entry(ri.id)
.or_insert_with(|| Vec::with_capacity(2));
.or_insert_with(|| Vec::with_capacity(3));
if info.is_staged {
if hashes.is_empty() || hashes[0] != *sha1 {
match info.kind {
RefKind::Head => {
hashes.push(sha1.clone());
}
} else {
RefKind::Staged | RefKind::InitialHead => {
if !hashes.contains(sha1) {
hashes.push(sha1.clone());
}
}
}
}
GitMetadata {
changed_ids,
@ -545,7 +591,7 @@ mod tests {
fs::write(dir.path().join("file.luau"), "content").unwrap();
git_commit_all(dir.path(), "init");
let changed = git_changed_files(dir.path());
let changed = git_changed_files(dir.path(), None);
assert!(changed.is_some());
assert!(changed.unwrap().is_empty());
}
@ -558,7 +604,7 @@ mod tests {
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("script.luau"), "modified").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("script.luau")));
}
@ -571,7 +617,7 @@ mod tests {
fs::write(dir.path().join("script.luau"), "staged version").unwrap();
git_stage(dir.path(), "script.luau");
let changed = git_changed_files(dir.path()).unwrap();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("script.luau")));
}
@ -583,7 +629,7 @@ mod tests {
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("new_file.luau"), "new").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("new_file.luau")));
}
@ -595,7 +641,7 @@ mod tests {
git_commit_all(dir.path(), "init");
fs::remove_file(dir.path().join("to_delete.luau")).unwrap();
let changed = git_changed_files(dir.path()).unwrap();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("to_delete.luau")));
}
@ -610,7 +656,7 @@ mod tests {
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();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("will_modify.luau")));
assert!(changed.contains(&PathBuf::from("untracked.luau")));
assert!(!changed.contains(&PathBuf::from("committed.luau")));
@ -620,11 +666,9 @@ mod tests {
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
let changed = git_changed_files(dir.path(), None);
assert!(changed.is_some());
assert!(changed.unwrap().contains(&PathBuf::from("new.luau")));
}
@ -638,10 +682,35 @@ mod tests {
git_commit_all(dir.path(), "init");
fs::write(dir.path().join("src/module.luau"), "modified").unwrap();
let changed = git_changed_files(dir.path()).unwrap();
let changed = git_changed_files(dir.path(), None).unwrap();
assert!(changed.contains(&PathBuf::from("src/module.luau")));
}
#[test]
fn changed_files_with_initial_head_finds_committed() {
let dir = tempdir().unwrap();
git_init(dir.path());
fs::write(dir.path().join("script.luau"), "original").unwrap();
git_commit_all(dir.path(), "init");
let initial_head = git_head_commit(dir.path()).unwrap();
fs::write(dir.path().join("script.luau"), "modified").unwrap();
git_commit_all(dir.path(), "edit");
let without = git_changed_files(dir.path(), None).unwrap();
assert!(
!without.contains(&PathBuf::from("script.luau")),
"without initial_head, committed file should NOT appear"
);
let with = git_changed_files(dir.path(), Some(&initial_head)).unwrap();
assert!(
with.contains(&PathBuf::from("script.luau")),
"with initial_head, committed file should appear"
);
}
// -----------------------------------------------------------------------
// git_show_head / git_show_staged
// -----------------------------------------------------------------------

View file

@ -122,6 +122,11 @@ pub struct ServeSession {
/// Root of the git repository, if the project is inside one.
/// Computed once at session start for use by auto-staging.
git_repo_root: Option<std::path::PathBuf>,
/// HEAD commit SHA at the time this serve session started.
/// Used as diff baseline so that files committed after session start
/// still appear in `changedIds` for auto-select defaults.
initial_head_commit: Option<String>,
}
/// Collect all filesystem paths reachable from the project tree's `$path`
@ -351,6 +356,9 @@ impl ServeSession {
};
let git_repo_root = crate::git::git_repo_root(root_project.folder_location());
let initial_head_commit = git_repo_root
.as_deref()
.and_then(crate::git::git_head_commit);
log::trace!("Starting ChangeProcessor");
let change_processor = ChangeProcessor::start(
@ -378,6 +386,7 @@ impl ServeSession {
suppressed_paths: Some(suppressed_paths),
ref_path_index: Some(ref_path_index),
git_repo_root,
initial_head_commit,
})
}
@ -404,6 +413,7 @@ impl ServeSession {
suppressed_paths: None,
ref_path_index: None,
git_repo_root: None,
initial_head_commit: None,
})
}
@ -492,6 +502,10 @@ impl ServeSession {
self.git_repo_root.as_deref()
}
pub fn initial_head_commit(&self) -> Option<&str> {
self.initial_head_commit.as_deref()
}
pub fn root_project(&self) -> &Project {
&self.root_project
}

View file

@ -379,7 +379,11 @@ impl ApiService {
};
let git_metadata = self.serve_session.git_repo_root().map(|repo_root| {
crate::git::compute_git_metadata(&self.serve_session.tree_handle(), repo_root)
crate::git::compute_git_metadata(
&self.serve_session.tree_handle(),
repo_root,
self.serve_session.initial_head_commit(),
)
});
msgpack_ok(&ServerInfoResponse {

View file

@ -965,3 +965,107 @@ fn stage_ids_stages_new_path_after_rename() {
"New file (RenamedModule.luau) should be staged after rename + stage_ids"
);
}
// ===========================================================================
// initial_head: committed changes still visible after git commit
// ===========================================================================
#[test]
fn committed_changes_still_in_changed_ids_via_initial_head() {
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");
fs::write(
session.path().join("src/ModuleA.luau"),
"-- committed after session start",
)
.unwrap();
git_commit_all(session.path(), "edit after serve");
let info2 = session.get_api_rojo().unwrap();
let meta2 = info2.git_metadata.unwrap();
assert!(
!meta2.changed_ids.is_empty(),
"Committed changes should still appear in changedIds via initial_head diff"
);
}
#[test]
fn committed_script_gets_initial_head_hash() {
let original_content = "local ModuleA = {}\nreturn ModuleA\n";
let modified_content = "-- committed v2\nreturn {}";
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
session.wait_to_come_online();
fs::write(session.path().join("src/ModuleA.luau"), modified_content).unwrap();
git_commit_all(session.path(), "edit after serve");
let info2 = session.get_api_rojo().unwrap();
let meta2 = info2.git_metadata.unwrap();
let read = session.get_api_read(info2.root_instance_id).unwrap();
if let Some((module_id, _)) = find_instance_by_name(&read.instances, "ModuleA") {
assert!(
meta2.changed_ids.contains(&module_id),
"Committed script should be in changedIds"
);
let hashes = meta2
.script_committed_hashes
.get(&module_id)
.expect("Committed script should have hashes");
let initial_hash = compute_blob_sha1(original_content);
assert!(
hashes.contains(&initial_hash),
"Hashes should include the initial_head version ({initial_hash}), got {hashes:?}"
);
}
}
#[test]
fn multiple_commits_after_session_start_all_tracked() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
session.wait_to_come_online();
fs::write(
session.path().join("src/ModuleA.luau"),
"-- commit 1 change",
)
.unwrap();
git_commit_all(session.path(), "commit 1");
fs::write(
session.path().join("src/ServerScript.server.luau"),
"-- commit 2 change",
)
.unwrap();
git_commit_all(session.path(), "commit 2");
let info2 = session.get_api_rojo().unwrap();
let meta2 = info2.git_metadata.unwrap();
let read = session.get_api_read(info2.root_instance_id).unwrap();
if let Some((module_id, _)) = find_instance_by_name(&read.instances, "ModuleA") {
assert!(
meta2.changed_ids.contains(&module_id),
"ModuleA (committed in first commit) should be in changedIds"
);
}
if let Some((server_id, _)) = find_instance_by_name(&read.instances, "ServerScript") {
assert!(
meta2.changed_ids.contains(&server_id),
"ServerScript (committed in second commit) should be in changedIds"
);
}
}