Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 70 additions & 65 deletions src/git/versioned_store/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

use super::{HistoricalAccess, HistoricalCommitAccess, TreeConfigSaver, VersionedKvStore};
use super::{
HistoricalAccess, HistoricalCommitAccess, TreeConfigSaver, VersionedKvStore,
DEFAULT_TREE_CONFIG_FILENAME,
};
use crate::config::TreeConfig;
use crate::diff::{ConflictResolver, IgnoreConflictsResolver};
use crate::digest::ValueDigest;
Expand Down Expand Up @@ -43,24 +46,14 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
self.metadata.repo()
}

/// Save both tree config and hash mappings to git for GitNodeStorage
/// Write the tree config + hash mappings to the dataset directory so
/// they get picked up by the next git commit.
fn save_tree_config_to_git(&self) -> Result<(), GitKvError> {
// For GitNodeStorage, we need to ensure the config and hash mappings are
// available in the dataset directory so they can be committed to git

// Get the current tree configuration
let config = self.tree.config.clone();

// Serialize the configuration to JSON
let config_json = serde_json::to_string_pretty(&config)
.map_err(|e| GitKvError::GitObjectError(format!("Failed to serialize config: {e}")))?;

// Write config to the dataset directory
let config_path = self
.tree
.storage
.dataset_dir()
.join("prolly_config_tree_config");
let config_path = self.tree.storage.dataset_dir().join(&self.config_filename);
std::fs::write(&config_path, config_json)
.map_err(|e| GitKvError::GitObjectError(format!("Failed to write config file: {e}")))?;

Expand Down Expand Up @@ -533,11 +526,22 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
Ok(())
}

/// Initialize a new versioned KV store with Git storage (default)
/// Initialize a new versioned KV store with Git storage (default).
pub fn init<P: AsRef<Path>>(path: P) -> Result<Self, GitKvError> {
Self::init_with_config_filename(path, DEFAULT_TREE_CONFIG_FILENAME)
}

/// Like [`init`], but persists the serialized tree config to
/// `config_filename` instead of the default. Used by
/// `NamespacedKvStore` so its inner store doesn't share a root-hash
/// file with a sibling `VersionedKvStore` in the same dataset_dir.
pub fn init_with_config_filename<P: AsRef<Path>>(
path: P,
config_filename: &str,
) -> Result<Self, GitKvError> {
let path = path.as_ref();

// Safety check: prevent initializing at git root to avoid `git add -A .` staging all files
// Refuse to init at git root `git add -A .` would stage everything.
if Self::is_in_git_root(path)? {
return Err(GitKvError::GitObjectError(
"Cannot initialize git-prolly in git root directory. \
Expand All @@ -547,36 +551,29 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
));
}

// Find the git repository
let git_root = Self::find_git_root(path).ok_or_else(|| {
GitKvError::GitObjectError(
"Not inside a git repository. Please run from within a git repository.".to_string(),
)
})?;

// For GitVersionedKvStore, use the user-provided path as the dataset directory
// This allows config files to be versioned in git commits
let dataset_dir = path.to_path_buf();
std::fs::create_dir_all(&dataset_dir).map_err(|e| {
GitKvError::GitObjectError(format!("Failed to create dataset directory: {e}"))
})?;

// Check if the store is already initialized by looking for config files
let config_path = dataset_dir.join("prolly_config_tree_config");
// Idempotent init: also check the default filename so reopening an
// old default-layout store as namespaced doesn't double-initialize.
let config_path = dataset_dir.join(config_filename);
let default_config_path = dataset_dir.join(DEFAULT_TREE_CONFIG_FILENAME);
let mappings_path = dataset_dir.join("prolly_hash_mappings");

if config_path.exists() || mappings_path.exists() {
// Store already exists, use open instead to load existing configuration
return Self::open(path);
if config_path.exists() || default_config_path.exists() || mappings_path.exists() {
return Self::open_with_config_filename(path, config_filename);
}

// Open the existing git repository
let git_repo = gix::open(&git_root).map_err(|e| GitKvError::GitOpenError(Box::new(e)))?;

// Create GitNodeStorage with user-provided path for versioned files
let storage = GitNodeStorage::new(git_repo.clone(), dataset_dir.clone())?;

// Create ProllyTree with default config
let config: TreeConfig<N> = TreeConfig::default();
let tree = ProllyTree::new(storage, config);

Expand All @@ -587,22 +584,29 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
current_branch: "main".to_string(),
storage_backend: StorageBackend::Git,
dataset_dir: Some(dataset_dir),
config_filename: config_filename.to_string(),
};

// Save initial configuration
let _ = store.tree.save_config();

// Create initial commit (which will include prolly metadata files)
store.commit("Initial commit")?;

Ok(store)
}

/// Open an existing versioned KV store with Git storage (default)
/// Open an existing versioned KV store with Git storage (default).
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, GitKvError> {
Self::open_with_config_filename(path, DEFAULT_TREE_CONFIG_FILENAME)
}

/// Like [`open`], but reads the serialized tree config from
/// `config_filename` instead of the default.
pub fn open_with_config_filename<P: AsRef<Path>>(
path: P,
config_filename: &str,
) -> Result<Self, GitKvError> {
let path = path.as_ref();

// Safety check: prevent opening at git root to avoid `git add -A .` staging all files
// Refuse to open at git root `git add -A .` would stage everything.
if Self::is_in_git_root(path)? {
return Err(GitKvError::GitObjectError(
"Cannot open git-prolly in git root directory. \
Expand All @@ -612,56 +616,54 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
));
}

// Find the git repository
let git_root = Self::find_git_root(path).ok_or_else(|| {
GitKvError::GitObjectError(
"Not inside a git repository. Please run from within a git repository.".to_string(),
)
})?;

// For GitVersionedKvStore, use the user-provided path as the dataset directory
// This allows config files to be versioned in git commits
let dataset_dir = path.to_path_buf();

// Open existing Git repository
let git_repo = gix::open(&git_root).map_err(|e| GitKvError::GitOpenError(Box::new(e)))?;

// Create GitNodeStorage with user-provided path for versioned files
let storage = GitNodeStorage::new(git_repo.clone(), dataset_dir.clone())?;

// Load tree configuration from dataset directory
let config_path = dataset_dir.join("prolly_config_tree_config");
if !config_path.exists() {
return Err(GitKvError::GitObjectError(
"Config file not found. The store may not be initialized. \
Call init() to create a new store."
.to_string(),
));
}
let config_data = std::fs::read_to_string(&config_path)
// Resolve which config file to load: prefer the requested filename,
// fall back to the default for pre-split stores. The next commit
// through this handle will write the requested filename, completing
// the migration silently.
let config_path = dataset_dir.join(config_filename);
let fallback_path = dataset_dir.join(DEFAULT_TREE_CONFIG_FILENAME);
let resolved_path = if config_path.exists() {
config_path
} else if fallback_path.exists() && config_filename != DEFAULT_TREE_CONFIG_FILENAME {
fallback_path
} else {
return Err(GitKvError::GitObjectError(format!(
"Config file not found ({} or {}). The store may not be \
initialized. Call init() to create a new store.",
config_filename, DEFAULT_TREE_CONFIG_FILENAME,
)));
};

let config_data = std::fs::read_to_string(&resolved_path)
.map_err(|e| GitKvError::GitObjectError(format!("Failed to read config file: {e}")))?;
let config: TreeConfig<N> = serde_json::from_str(&config_data)
.map_err(|e| GitKvError::GitObjectError(format!("Failed to parse config file: {e}")))?;

// Try to load existing tree from storage
let tree = if let Some(existing_tree) =
ProllyTree::load_from_storage(storage.clone(), config.clone())
{
existing_tree
} else if config.root_hash.is_some() {
// We have a saved root hash but failed to load the tree
// This could be due to missing hash mappings or git objects
// For read-only operations, we should try to work with what we have
// rather than creating a new empty tree that would overwrite the config
// Saved hash but can't load — likely missing git objects or hash
// mappings. Keep the config rather than overwriting with empty so
// read-side callers can still try to work with what's there.
eprintln!("Warning: Failed to load tree from saved root hash. This may indicate missing git objects or corrupted hash mappings.");
eprintln!("Attempting to create tree with saved config to avoid data loss...");
ProllyTree::new(storage, config)
} else {
// No saved root hash - this is a genuinely new/empty tree
ProllyTree::new(storage, config)
};

// Get current branch
let current_branch = git_repo
.head_ref()
.map_err(|e| GitKvError::GitObjectError(format!("Failed to get head ref: {e}")))?
Expand All @@ -675,16 +677,14 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
current_branch,
storage_backend: StorageBackend::Git,
dataset_dir: Some(dataset_dir),
config_filename: config_filename.to_string(),
};

// Load staging area from file if it exists
store.load_staging_area()?;

// Note: We intentionally do NOT call reload_tree_from_head() here
// because git-prolly commands should read from the current directory's
// prolly_config_tree_config and mapping files, not from git HEAD.
// The tree was already loaded from local storage by load_from_storage() above.

// Don't reload_tree_from_head — git-prolly reads from the dataset
// directory's config/mapping files, not git HEAD. The tree was
// already loaded from local storage above.
Ok(store)
}

Expand Down Expand Up @@ -721,7 +721,7 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
.collect::<Vec<_>>()
.join("/");

let config_path = format!("{}/prolly_config_tree_config", relative_path_str);
let config_path = format!("{}/{}", relative_path_str, self.config_filename);
let mapping_path = format!("{}/prolly_hash_mappings", relative_path_str);

let config_result = self.metadata.read_file_at_commit(commit_id, &config_path);
Expand Down Expand Up @@ -916,6 +916,7 @@ impl<const N: usize> VersionedKvStore<N, InMemoryNodeStorage<N>, GitMetadataBack
current_branch: "main".to_string(),
storage_backend: StorageBackend::InMemory,
dataset_dir: Some(dataset_dir),
config_filename: DEFAULT_TREE_CONFIG_FILENAME.to_string(),
};

// Create initial commit
Expand Down Expand Up @@ -1019,6 +1020,7 @@ impl<const N: usize> VersionedKvStore<N, FileNodeStorage<N>, GitMetadataBackend>
current_branch: "main".to_string(),
storage_backend: StorageBackend::File,
dataset_dir: Some(dataset_dir),
config_filename: DEFAULT_TREE_CONFIG_FILENAME.to_string(),
};

// Create initial commit (which will save config to dataset_dir)
Expand Down Expand Up @@ -1120,6 +1122,7 @@ impl<const N: usize> VersionedKvStore<N, FileNodeStorage<N>, GitMetadataBackend>
current_branch,
storage_backend: StorageBackend::File,
dataset_dir: Some(dataset_dir),
config_filename: DEFAULT_TREE_CONFIG_FILENAME.to_string(),
};

// Load staging area from file if it exists
Expand Down Expand Up @@ -1215,6 +1218,7 @@ impl<const N: usize> VersionedKvStore<N, RocksDBNodeStorage<N>, GitMetadataBacke
current_branch: "main".to_string(),
storage_backend: StorageBackend::RocksDB,
dataset_dir: Some(dataset_dir),
config_filename: DEFAULT_TREE_CONFIG_FILENAME.to_string(),
};

// Create initial commit (which will save config to dataset_dir)
Expand Down Expand Up @@ -1306,6 +1310,7 @@ impl<const N: usize> VersionedKvStore<N, RocksDBNodeStorage<N>, GitMetadataBacke
current_branch,
storage_backend: StorageBackend::RocksDB,
dataset_dir: Some(dataset_dir),
config_filename: DEFAULT_TREE_CONFIG_FILENAME.to_string(),
};

// Load staging area from file if it exists
Expand Down
13 changes: 7 additions & 6 deletions src/git/versioned_store/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,8 @@ where
GitKvError::GitObjectError("Dataset directory is not inside git repository".to_string())
})?;

// Construct the file path and use '/' separators for git tree paths
// (git uses forward slashes regardless of platform)
let config_path = relative_path.join("prolly_config_tree_config");
// git tree paths always use forward slashes.
let config_path = relative_path.join(&self.config_filename);
let path_str = config_path
.components()
.map(|c| c.as_os_str().to_string_lossy())
Expand All @@ -69,8 +68,7 @@ where
Ok(path_str)
}

/// Read the tree config from a specific commit
/// This gets the prolly_config_tree_config file from the commit to extract root hash
/// Read the tree config from a specific commit.
pub(super) fn read_tree_config_from_commit(
&self,
commit_id: &gix::ObjectId,
Expand All @@ -86,7 +84,10 @@ where
Ok(tree_config)
}
Err(_) => {
eprintln!("Warning: prolly_config_tree_config not found in commit {commit_id}, using default config");
eprintln!(
"Warning: {} not found in commit {commit_id}, using default config",
self.config_filename
);
Ok(TreeConfig::default())
}
}
Expand Down
17 changes: 14 additions & 3 deletions src/git/versioned_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,23 @@ pub struct VersionedKvStore<
pub(crate) staging_area: HashMap<Vec<u8>, Option<Vec<u8>>>, // None = deleted
pub(crate) current_branch: String,
pub(crate) storage_backend: StorageBackend,
/// Dataset directory for storing config and other metadata for all backends
/// (File/RocksDB/InMemory/Git). Git init/open paths set this from
/// `storage.dataset_dir()`, and commit()/merge rely on this field being `Some`.
/// Dataset directory for config and other metadata. Set from
/// `storage.dataset_dir()` at init/open; commit/merge require `Some`.
pub(crate) dataset_dir: Option<std::path::PathBuf>,
/// Filename under `dataset_dir` for the serialized `TreeConfig`. Lets
/// `NamespacedKvStore` route its inner store to a distinct file so the
/// two store types can share a dataset directory.
pub(crate) config_filename: String,
}

/// Default `TreeConfig` filename written by `VersionedKvStore`.
pub const DEFAULT_TREE_CONFIG_FILENAME: &str = "prolly_config_tree_config";

/// `TreeConfig` filename used when a `VersionedKvStore` is the inner
/// store of a `NamespacedKvStore`. Kept separate from
/// `DEFAULT_TREE_CONFIG_FILENAME` so both layouts can share a directory.
pub const NAMESPACED_TREE_CONFIG_FILENAME: &str = "prolly_config_namespaced_root";

/// Type alias for backward compatibility (Git storage)
pub type GitVersionedKvStore<const N: usize> = VersionedKvStore<N, GitNodeStorage<N>>;

Expand Down
Loading
Loading