feat: add --update-bundle with provenance-aware conflict handling
Updates re-clone a bundle's recorded source (honoring a recorded commit pin unless a #<ref> override moves it), silently refresh files the bundle owns that the user never modified, and fall back to the normal conflict prompts for modified or unowned files. Files the remote no longer ships are offered for deletion (kept by default non-interactively, staying owned). The record is refreshed with the new commit, version, and metadata, and stamped with an updated_at timestamp on success.
This commit is contained in:
+92
-1
@@ -65,6 +65,8 @@ pub(crate) struct BundleRecord {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) homepage: Option<String>,
|
||||
pub(crate) installed_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) files: Vec<FileRecord>,
|
||||
#[serde(default)]
|
||||
@@ -153,7 +155,6 @@ impl BundleStore {
|
||||
.with_context(|| format!("failed to write {}", self.path.display()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> {
|
||||
self.bundles.get(name)
|
||||
}
|
||||
@@ -308,6 +309,7 @@ impl BundleStore {
|
||||
description: metadata.description,
|
||||
homepage: metadata.homepage,
|
||||
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
updated_at: None,
|
||||
files: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
@@ -317,6 +319,29 @@ impl BundleStore {
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Stamp the record with the time of its most recent update from source.
|
||||
pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> {
|
||||
self.ensure_bundle_exists(name)?;
|
||||
let record = self
|
||||
.bundles
|
||||
.get_mut(name)
|
||||
.expect("bundle existence checked above");
|
||||
record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true));
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Drop one path from a bundle's owned files and persist. Used when the
|
||||
/// bundle no longer ships the file and it is gone (or deleted) locally.
|
||||
pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> {
|
||||
self.ensure_bundle_exists(bundle)?;
|
||||
let record = self
|
||||
.bundles
|
||||
.get_mut(bundle)
|
||||
.expect("bundle existence checked above");
|
||||
record.files.retain(|owned| owned.path != path);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Record one written file and persist immediately, so an install aborted
|
||||
/// partway through still has provenance for everything already on disk.
|
||||
/// A path owned by another bundle transfers to `bundle`.
|
||||
@@ -967,6 +992,72 @@ mod tests {
|
||||
assert!(result.unwrap_err().to_string().contains("ghost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_updated_stamps_and_round_trips() {
|
||||
let dir = TempStoreDir::new("bundles-mark-updated");
|
||||
let mut store = dir.store();
|
||||
store
|
||||
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||
.unwrap();
|
||||
let raw_before = fs::read_to_string(dir.store_path()).unwrap();
|
||||
assert!(!raw_before.contains("updated_at"), "{raw_before}");
|
||||
assert_eq!(store.get("omc").unwrap().updated_at, None);
|
||||
|
||||
store.mark_updated("omc").unwrap();
|
||||
|
||||
let raw_after = fs::read_to_string(dir.store_path()).unwrap();
|
||||
assert!(raw_after.contains("updated_at"), "{raw_after}");
|
||||
let reloaded = dir.store();
|
||||
assert!(reloaded.get("omc").unwrap().updated_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_updated_unknown_bundle_fails() {
|
||||
let dir = TempStoreDir::new("bundles-mark-unknown");
|
||||
let mut store = dir.store();
|
||||
|
||||
let result = store.mark_updated("ghost");
|
||||
|
||||
assert!(result.unwrap_err().to_string().contains("ghost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_file_record_drops_only_the_named_path() {
|
||||
let dir = TempStoreDir::new("bundles-remove-file");
|
||||
let mut store = dir.store();
|
||||
store
|
||||
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||
.unwrap();
|
||||
store
|
||||
.record_file("omc", file_record("macros/a.yaml", "a"))
|
||||
.unwrap();
|
||||
store
|
||||
.record_file("omc", file_record("macros/b.yaml", "b"))
|
||||
.unwrap();
|
||||
|
||||
store.remove_file_record("omc", "macros/a.yaml").unwrap();
|
||||
|
||||
let reloaded = dir.store();
|
||||
let paths: Vec<&str> = reloaded
|
||||
.get("omc")
|
||||
.unwrap()
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(paths, vec!["macros/b.yaml"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_file_record_unknown_bundle_fails() {
|
||||
let dir = TempStoreDir::new("bundles-remove-unknown");
|
||||
let mut store = dir.store();
|
||||
|
||||
let result = store.remove_file_record("ghost", "macros/a.yaml");
|
||||
|
||||
assert!(result.unwrap_err().to_string().contains("ghost"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_save_preserves_the_existing_store() {
|
||||
|
||||
Reference in New Issue
Block a user