Skip to content

Commit 3df1e0f

Browse files
authored
Fix GH-161 and GH-162 (#163)
* Fix merge data corruption when Git backend is used * fix GH-161 — checkout left git status dirty
1 parent 2806583 commit 3df1e0f

4 files changed

Lines changed: 726 additions & 54 deletions

File tree

src/git/versioned_store/backends.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,23 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
6464
std::fs::write(&config_path, config_json)
6565
.map_err(|e| GitKvError::GitObjectError(format!("Failed to write config file: {e}")))?;
6666

67-
// Get hash mappings from storage and save them
67+
// Get hash mappings from storage and save them, sorted by hash bytes so that
68+
// the on-disk file is byte-deterministic for a given set of mappings. Without
69+
// this, HashMap iteration order varies between processes and `git status`
70+
// spuriously reports `prolly_hash_mappings` as modified after checkout /
71+
// reload even though the logical mapping set is unchanged (see GH-161).
6872
let mappings = self.tree.storage.get_hash_mappings();
73+
let mut entries: Vec<(String, String)> = mappings
74+
.iter()
75+
.map(|(hash, object_id)| {
76+
let hash_hex: String = hash.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
77+
(hash_hex, object_id.to_hex().to_string())
78+
})
79+
.collect();
80+
entries.sort();
6981
let mut mappings_content = String::new();
70-
for (hash, object_id) in mappings {
71-
// Convert hash bytes to hex manually
72-
let hash_hex: String = hash.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
73-
mappings_content.push_str(&format!("{hash_hex}:{object_id}\n"));
82+
for (hash_hex, object_hex) in &entries {
83+
mappings_content.push_str(&format!("{hash_hex}:{object_hex}\n"));
7484
}
7585

7686
// Write mappings to the dataset directory
@@ -114,6 +124,10 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
114124
}
115125
}
116126

127+
// Sync working tree and index under the dataset dir to match the new HEAD
128+
// so `git status` is clean afterward (see GH-161).
129+
self.sync_working_tree_to_head()?;
130+
117131
// Git-specific: Reload the tree from the HEAD commit of the target branch
118132
self.reload_tree_from_head()?;
119133

src/git/versioned_store/history.rs

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,12 @@ where
289289
// Get the current HEAD commit
290290
let head_object_id = self.metadata.head_commit_id()?;
291291

292-
// Load all key-value pairs from the HEAD commit using HistoricalAccess
293-
let keys_at_head = self.collect_keys_from_commit_generic(&head_object_id)?;
292+
// Load keys via HistoricalAccess::get_keys_at_ref so the Git backend reads
293+
// the per-commit `prolly_hash_mappings` blob (via `collect_keys_at_commit`)
294+
// rather than looking up the commit's root hash in the current in-memory
295+
// mappings, which can be narrower than the commit's view after a
296+
// `git reset` / working-tree switch (see GH-162).
297+
let keys_at_head = self.get_keys_at_ref(&head_object_id.to_hex().to_string())?;
294298

295299
// Get the config from the commit
296300
let config = self.read_tree_config_from_commit(&head_object_id)?;
@@ -311,18 +315,6 @@ where
311315
Ok(())
312316
}
313317

314-
/// Collect all key-value pairs from a specific commit (generic version)
315-
pub(super) fn collect_keys_from_commit_generic(
316-
&self,
317-
commit_id: &gix::ObjectId,
318-
) -> Result<HashMap<Vec<u8>, Vec<u8>>, GitKvError> {
319-
// Read the tree config from the commit
320-
let tree_config = self.read_tree_config_from_commit(commit_id)?;
321-
322-
// Use the generic collect_keys_from_config which works for all storage types
323-
self.collect_keys_from_config(&tree_config)
324-
}
325-
326318
/// Switch to a different branch or commit (generic version for all backends)
327319
pub fn checkout_generic(&mut self, branch_or_commit: &str) -> Result<(), GitKvError>
328320
where
@@ -339,12 +331,77 @@ where
339331
// Update HEAD to point to the new branch
340332
self.metadata.update_head(branch_or_commit)?;
341333

334+
// Sync git's index and working tree under the dataset directory to match
335+
// the new HEAD. Without this, prolly's own committed files
336+
// (prolly_config_tree_config, prolly_hash_mappings, ...) retain the previous
337+
// branch's content on disk and `git status` reports them as modified —
338+
// confusing any outside tool working against the repo (see GH-161). Doing
339+
// the sync before `reload_tree_from_head_generic` also makes the in-memory
340+
// tree consistent with what any subsequent git-level reader would see.
341+
self.sync_working_tree_to_head()?;
342+
342343
// Reload the tree from the HEAD commit
343344
self.reload_tree_from_head_generic()?;
344345

345346
Ok(())
346347
}
347348

349+
/// Restore the working tree and index under the dataset directory to match the
350+
/// current HEAD commit. This is the moral equivalent of `git checkout HEAD -- .`
351+
/// scoped to `dataset_dir`, so non-prolly files living outside the dataset are
352+
/// left untouched. Used by `checkout_generic` (GH-161).
353+
pub(super) fn sync_working_tree_to_head(&self) -> Result<(), GitKvError> {
354+
let dataset_dir = self
355+
.dataset_dir
356+
.as_ref()
357+
.ok_or_else(|| GitKvError::GitObjectError("Dataset directory not set".into()))?;
358+
let git_root = self
359+
.metadata
360+
.work_dir()
361+
.or_else(|| Self::find_git_root(dataset_dir))
362+
.ok_or_else(|| GitKvError::GitObjectError("Could not find git root".into()))?;
363+
364+
let relative = dataset_dir.strip_prefix(&git_root).map_err(|e| {
365+
GitKvError::GitObjectError(format!("dataset_dir not under git_root: {e}"))
366+
})?;
367+
// `.` when the dataset is the repo root; otherwise the relative path.
368+
let relative_str = if relative.as_os_str().is_empty() {
369+
".".to_string()
370+
} else {
371+
relative.to_string_lossy().replace('\\', "/")
372+
};
373+
374+
// `git checkout HEAD -- <path>` rewrites both the index and the working tree
375+
// under <path> to match HEAD. If HEAD doesn't track a file that exists in
376+
// the working tree, git leaves it alone — so user-owned untracked files in
377+
// the dataset directory are preserved. Unlike `git reset --hard`, this does
378+
// not touch anything outside the given pathspec.
379+
let output = std::process::Command::new("git")
380+
.args(["checkout", "HEAD", "--", &relative_str])
381+
.current_dir(&git_root)
382+
.output()
383+
.map_err(|e| {
384+
GitKvError::GitObjectError(format!("Failed to run `git checkout HEAD -- .`: {e}"))
385+
})?;
386+
387+
if !output.status.success() {
388+
let stderr = String::from_utf8_lossy(&output.stderr);
389+
// On an empty / non-existent path in HEAD, git complains but we can
390+
// safely treat that as a no-op (e.g. first checkout on a fresh repo).
391+
if stderr.contains("did not match any file")
392+
|| stderr.contains("pathspec")
393+
|| stderr.contains("error: pathspec")
394+
{
395+
return Ok(());
396+
}
397+
return Err(GitKvError::GitObjectError(format!(
398+
"`git checkout HEAD -- {relative_str}` failed: {stderr}"
399+
)));
400+
}
401+
402+
Ok(())
403+
}
404+
348405
/// Merge another branch into the current branch (generic version for all backends)
349406
pub fn merge_generic<R: ConflictResolver>(
350407
&mut self,
@@ -359,10 +416,19 @@ where
359416
// Find common base commit
360417
let base_commit = self.find_merge_base_generic(&dest_branch, source_branch)?;
361418

362-
// Get key-value data from each state
363-
let base_kv = self.collect_keys_from_commit_generic(&base_commit)?;
419+
// Get key-value data from each state via HistoricalAccess::get_keys_at_ref.
420+
//
421+
// The Git backend's specialization reads the per-commit `prolly_hash_mappings`
422+
// blob out of each commit's tree (see `collect_keys_at_commit`), so it works
423+
// even when the working-tree mappings file was narrowed by `git reset` /
424+
// `git checkout` back to a single branch's view (see GH-162). Using the
425+
// generic `collect_keys_from_config` path here would look the commit's root
426+
// hash up in the *current in-memory* mappings and spuriously return an empty
427+
// set for roots that only existed on the other branch.
364428
let source_commit = self.get_branch_commit_generic(source_branch)?;
365-
let source_kv = self.collect_keys_from_commit_generic(&source_commit)?;
429+
let base_kv = self.get_keys_at_ref(&base_commit.to_hex().to_string())?;
430+
let source_kv = self.get_keys_at_ref(&source_commit.to_hex().to_string())?;
431+
366432
let mut dest_kv = HashMap::new();
367433

368434
for key in self.tree.collect_keys() {

0 commit comments

Comments
 (0)