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:
+12
-1
@@ -53,7 +53,7 @@ pub enum McpScopeArg {
|
|||||||
"install_from", "sync_models", "list_models", "list_roles",
|
"install_from", "sync_models", "list_models", "list_roles",
|
||||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||||
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
||||||
"update",
|
"update", "update_bundle",
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
group(
|
group(
|
||||||
@@ -207,6 +207,9 @@ pub struct Cli {
|
|||||||
help_heading = "Installation & Updates"
|
help_heading = "Installation & Updates"
|
||||||
)]
|
)]
|
||||||
pub install_force: bool,
|
pub install_force: bool,
|
||||||
|
/// Update an installed bundle from its recorded source (NAME may be suffixed with #<ref> to move a pin)
|
||||||
|
#[arg(long, value_name = "NAME", help_heading = "Installation & Updates")]
|
||||||
|
pub update_bundle: Option<String>,
|
||||||
/// Sync models updates
|
/// Sync models updates
|
||||||
#[arg(long, help_heading = "Installation & Updates")]
|
#[arg(long, help_heading = "Installation & Updates")]
|
||||||
pub sync_models: bool,
|
pub sync_models: bool,
|
||||||
@@ -508,6 +511,14 @@ mod tests {
|
|||||||
assert!(parse(&[]).skill.is_empty());
|
assert!(parse(&[]).skill.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_update_bundle_flag_takes_name() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--update-bundle", "foo"]).update_bundle.as_deref(),
|
||||||
|
Some("foo")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_multiple_skill_flags_preserves_order() {
|
fn parse_multiple_skill_flags_preserves_order() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+92
-1
@@ -65,6 +65,8 @@ pub(crate) struct BundleRecord {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub(crate) homepage: Option<String>,
|
pub(crate) homepage: Option<String>,
|
||||||
pub(crate) installed_at: String,
|
pub(crate) installed_at: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) files: Vec<FileRecord>,
|
pub(crate) files: Vec<FileRecord>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -153,7 +155,6 @@ impl BundleStore {
|
|||||||
.with_context(|| format!("failed to write {}", self.path.display()))
|
.with_context(|| format!("failed to write {}", self.path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> {
|
pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> {
|
||||||
self.bundles.get(name)
|
self.bundles.get(name)
|
||||||
}
|
}
|
||||||
@@ -308,6 +309,7 @@ impl BundleStore {
|
|||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
homepage: metadata.homepage,
|
homepage: metadata.homepage,
|
||||||
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
updated_at: None,
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -317,6 +319,29 @@ impl BundleStore {
|
|||||||
self.save()
|
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
|
/// Record one written file and persist immediately, so an install aborted
|
||||||
/// partway through still has provenance for everything already on disk.
|
/// partway through still has provenance for everything already on disk.
|
||||||
/// A path owned by another bundle transfers to `bundle`.
|
/// A path owned by another bundle transfers to `bundle`.
|
||||||
@@ -967,6 +992,72 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().to_string().contains("ghost"));
|
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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_save_preserves_the_existing_store() {
|
fn failed_save_preserves_the_existing_store() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use indexmap::IndexMap;
|
|||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
use inquire::{Confirm, Select};
|
use inquire::{Confirm, Select};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -103,6 +104,208 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> {
|
|||||||
install_remote(&url, filter, force)
|
install_remote(&url, filter, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update an installed bundle from its recorded source. `spec` is the bundle
|
||||||
|
/// name, optionally suffixed with `#<ref>` to move a pinned ref. The whole
|
||||||
|
/// remote is always processed — including categories a filtered install
|
||||||
|
/// excluded — because filtered installs merge into a single record and an
|
||||||
|
/// update brings that record in line with everything the remote now ships.
|
||||||
|
pub fn update_bundle(spec: &str) -> Result<()> {
|
||||||
|
let (name, ref_override) = parse_url_with_ref(spec)?;
|
||||||
|
|
||||||
|
let mut store = BundleStore::load()?;
|
||||||
|
let Some(record) = store.get(&name) else {
|
||||||
|
let installed = store.bundle_names();
|
||||||
|
if installed.is_empty() {
|
||||||
|
bail!("no bundle named '{name}' is installed; none are installed");
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"no bundle named '{name}' is installed; installed bundles: {}",
|
||||||
|
installed.join(", ")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let source = record.source.clone();
|
||||||
|
let recorded_ref = record.git_ref.clone();
|
||||||
|
|
||||||
|
let has_override = ref_override.is_some();
|
||||||
|
let effective_ref = ref_override.or(recorded_ref);
|
||||||
|
if !has_override
|
||||||
|
&& let Some(pinned) = effective_ref.as_deref()
|
||||||
|
&& is_commit_sha(pinned)
|
||||||
|
{
|
||||||
|
println!("Bundle '{name}' is pinned to commit {pinned}; pass #<ref> to move the pin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp = clone_to_temp(&source, effective_ref.as_deref())?;
|
||||||
|
println!("Cloned {source} to {}", temp.path().display());
|
||||||
|
|
||||||
|
let mut layout = scan_remote_layout(temp.path())?;
|
||||||
|
layout.head_sha = Some(temp.head_sha().to_string());
|
||||||
|
if layout.is_empty() {
|
||||||
|
println!(
|
||||||
|
"The source for '{name}' no longer contains recognized assets; \
|
||||||
|
leaving installed files and the bundle record untouched."
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundle = register_bundle(
|
||||||
|
&mut store,
|
||||||
|
&source,
|
||||||
|
effective_ref.as_deref(),
|
||||||
|
layout.manifest.as_ref(),
|
||||||
|
temp.head_sha(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let plan = plan_changes(&layout)?;
|
||||||
|
let plan = reclassify_owned_unmodified(plan, &store, &bundle)?;
|
||||||
|
|
||||||
|
if !plan.files.is_empty() {
|
||||||
|
print_plan_summary(&plan);
|
||||||
|
apply_plan(&plan, StickyMode::None, &mut store, &bundle)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, &bundle, &plan)?;
|
||||||
|
|
||||||
|
if let Some((remote_mcp, local_mcp)) = &plan.mcp_json {
|
||||||
|
let local = local_mcp.exists().then_some(local_mcp.as_path());
|
||||||
|
let report = merge_mcp_json(local, remote_mcp, local_mcp, false)?;
|
||||||
|
record_mcp_merge(&mut store, &bundle, &report)?;
|
||||||
|
print_mcp_merge_report(&report);
|
||||||
|
handle_missing_secrets(&report.missing_secrets)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mark_updated(&bundle)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conflict on a file this bundle owns whose on-disk content still matches
|
||||||
|
/// the recorded hash is not a real conflict: the bundle wrote that content and
|
||||||
|
/// the user never touched it, so an update refreshes it without prompting.
|
||||||
|
/// Files the user modified — or that another bundle owns — keep the normal
|
||||||
|
/// conflict semantics.
|
||||||
|
fn reclassify_owned_unmodified(
|
||||||
|
mut plan: InstallPlan,
|
||||||
|
store: &BundleStore,
|
||||||
|
bundle: &str,
|
||||||
|
) -> Result<InstallPlan> {
|
||||||
|
let owned: HashMap<&str, &str> = store
|
||||||
|
.get(bundle)
|
||||||
|
.map(|record| {
|
||||||
|
record
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| (file.path.as_str(), file.sha256.as_str()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for planned in &mut plan.files {
|
||||||
|
if planned.kind != PlannedKind::Conflict {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if hash_file(&planned.dst)? == *recorded {
|
||||||
|
planned.kind = PlannedKind::Refresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ObsoleteAction {
|
||||||
|
Keep,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile owned files the remote no longer ships. A file already gone from
|
||||||
|
/// disk just drops out of the record; a file still present is kept by default
|
||||||
|
/// (the user may rely on it) and only deleted on explicit confirmation. Kept
|
||||||
|
/// files stay in the record, so a later uninstall still offers to remove them.
|
||||||
|
fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPlan) -> Result<()> {
|
||||||
|
let planned: HashSet<String> = plan
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|planned| provenance_path(&planned.dst))
|
||||||
|
.collect();
|
||||||
|
let obsolete: Vec<String> = store
|
||||||
|
.get(bundle)
|
||||||
|
.map(|record| {
|
||||||
|
record
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.path.clone())
|
||||||
|
.filter(|path| !planned.contains(path))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let config_dir = paths::config_dir();
|
||||||
|
let mut sticky: Option<ObsoleteAction> = None;
|
||||||
|
for path in obsolete {
|
||||||
|
let full = config_dir.join(&path);
|
||||||
|
if !full.exists() {
|
||||||
|
println!("dropped record for obsolete file {path} (already absent locally)");
|
||||||
|
store.remove_file_record(bundle, &path)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let action = resolve_obsolete(&path, &mut sticky)?;
|
||||||
|
apply_obsolete_action(store, bundle, &path, &full, action)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_obsolete(path: &str, sticky: &mut Option<ObsoleteAction>) -> Result<ObsoleteAction> {
|
||||||
|
if let Some(action) = *sticky {
|
||||||
|
return Ok(action);
|
||||||
|
}
|
||||||
|
if !*IS_STDOUT_TERMINAL {
|
||||||
|
return Ok(ObsoleteAction::Keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = format!("Obsolete file {path} is no longer shipped by the bundle");
|
||||||
|
let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"])
|
||||||
|
.prompt()
|
||||||
|
.with_context(|| "failed to read obsolete-file choice")?;
|
||||||
|
|
||||||
|
match choice {
|
||||||
|
"keep" => Ok(ObsoleteAction::Keep),
|
||||||
|
"delete" => Ok(ObsoleteAction::Delete),
|
||||||
|
"keep-all" => {
|
||||||
|
*sticky = Some(ObsoleteAction::Keep);
|
||||||
|
Ok(ObsoleteAction::Keep)
|
||||||
|
}
|
||||||
|
"delete-all" => {
|
||||||
|
*sticky = Some(ObsoleteAction::Delete);
|
||||||
|
Ok(ObsoleteAction::Delete)
|
||||||
|
}
|
||||||
|
_ => unreachable!("inquire::Select returned an unexpected option"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_obsolete_action(
|
||||||
|
store: &mut BundleStore,
|
||||||
|
bundle: &str,
|
||||||
|
path: &str,
|
||||||
|
full: &Path,
|
||||||
|
action: ObsoleteAction,
|
||||||
|
) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
ObsoleteAction::Keep => {
|
||||||
|
println!("kept obsolete file {path} (no longer shipped by the bundle)");
|
||||||
|
}
|
||||||
|
ObsoleteAction::Delete => {
|
||||||
|
fs::remove_file(full)
|
||||||
|
.with_context(|| format!("failed to delete obsolete file {}", full.display()))?;
|
||||||
|
store.remove_file_record(bundle, path)?;
|
||||||
|
println!("deleted obsolete file {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_filter(name: &str) -> Result<InstallFilter> {
|
fn parse_filter(name: &str) -> Result<InstallFilter> {
|
||||||
InstallFilter::parse(name).with_context(|| {
|
InstallFilter::parse(name).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -160,13 +363,17 @@ impl Drop for TempRepoDir {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_commit_sha(reference: &str) -> bool {
|
||||||
|
reference.len() >= 4
|
||||||
|
&& reference.len() <= 40
|
||||||
|
&& reference.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
||||||
let dest = utils::temp_file("coyote-remote-install-", "");
|
let dest = utils::temp_file("coyote-remote-install-", "");
|
||||||
let dest_arg: OsString = dest.as_os_str().into();
|
let dest_arg: OsString = dest.as_os_str().into();
|
||||||
|
|
||||||
let is_sha = reference
|
let is_sha = reference.is_some_and(is_commit_sha);
|
||||||
.map(|r| r.len() >= 4 && r.len() <= 40 && r.chars().all(|c| c.is_ascii_hexdigit()))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
match reference {
|
match reference {
|
||||||
Some(r) if !is_sha => {
|
Some(r) if !is_sha => {
|
||||||
@@ -567,6 +774,9 @@ enum PlannedKind {
|
|||||||
New,
|
New,
|
||||||
Identical,
|
Identical,
|
||||||
Conflict,
|
Conflict,
|
||||||
|
/// A conflict downgraded because this bundle owns the file and the local
|
||||||
|
/// content still matches the recorded hash; applied without prompting.
|
||||||
|
Refresh,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlannedFile {
|
struct PlannedFile {
|
||||||
@@ -733,11 +943,16 @@ fn print_plan_summary(plan: &InstallPlan) {
|
|||||||
let new_ = count_kind(plan, cat, PlannedKind::New);
|
let new_ = count_kind(plan, cat, PlannedKind::New);
|
||||||
let identical = count_kind(plan, cat, PlannedKind::Identical);
|
let identical = count_kind(plan, cat, PlannedKind::Identical);
|
||||||
let conflict = count_kind(plan, cat, PlannedKind::Conflict);
|
let conflict = count_kind(plan, cat, PlannedKind::Conflict);
|
||||||
if new_ + identical + conflict > 0 {
|
let refresh = count_kind(plan, cat, PlannedKind::Refresh);
|
||||||
println!(
|
if new_ + identical + conflict + refresh > 0 {
|
||||||
|
let mut line = format!(
|
||||||
" {:<16} new={new_} identical={identical} conflict={conflict}",
|
" {:<16} new={new_} identical={identical} conflict={conflict}",
|
||||||
cat.label()
|
cat.label()
|
||||||
);
|
);
|
||||||
|
if refresh > 0 {
|
||||||
|
line.push_str(&format!(" refresh={refresh}"));
|
||||||
|
}
|
||||||
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -766,6 +981,7 @@ struct ApplyReport {
|
|||||||
new_count: usize,
|
new_count: usize,
|
||||||
identical_count: usize,
|
identical_count: usize,
|
||||||
replaced_count: usize,
|
replaced_count: usize,
|
||||||
|
refreshed_count: usize,
|
||||||
kept_count: usize,
|
kept_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,6 +995,7 @@ fn apply_plan(
|
|||||||
new_count: 0,
|
new_count: 0,
|
||||||
identical_count: 0,
|
identical_count: 0,
|
||||||
replaced_count: 0,
|
replaced_count: 0,
|
||||||
|
refreshed_count: 0,
|
||||||
kept_count: 0,
|
kept_count: 0,
|
||||||
};
|
};
|
||||||
let mut sticky = initial_mode;
|
let mut sticky = initial_mode;
|
||||||
@@ -793,6 +1010,11 @@ fn apply_plan(
|
|||||||
PlannedKind::Identical => {
|
PlannedKind::Identical => {
|
||||||
report.identical_count += 1;
|
report.identical_count += 1;
|
||||||
}
|
}
|
||||||
|
PlannedKind::Refresh => {
|
||||||
|
write_file(&planned.src, &planned.dst)?;
|
||||||
|
record_written_file(store, bundle, planned, FileAction::Replaced)?;
|
||||||
|
report.refreshed_count += 1;
|
||||||
|
}
|
||||||
PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? {
|
PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? {
|
||||||
ConflictAction::Keep => report.kept_count += 1,
|
ConflictAction::Keep => report.kept_count += 1,
|
||||||
ConflictAction::Replace => {
|
ConflictAction::Replace => {
|
||||||
@@ -804,10 +1026,21 @@ fn apply_plan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
if report.refreshed_count > 0 {
|
||||||
"\nInstalled: {} new, {} replaced, {} kept, {} identical.",
|
println!(
|
||||||
report.new_count, report.replaced_count, report.kept_count, report.identical_count
|
"\nInstalled: {} new, {} refreshed, {} replaced, {} kept, {} identical.",
|
||||||
);
|
report.new_count,
|
||||||
|
report.refreshed_count,
|
||||||
|
report.replaced_count,
|
||||||
|
report.kept_count,
|
||||||
|
report.identical_count
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"\nInstalled: {} new, {} replaced, {} kept, {} identical.",
|
||||||
|
report.new_count, report.replaced_count, report.kept_count, report.identical_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
@@ -2442,4 +2675,415 @@ mod tests {
|
|||||||
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
||||||
assert!(!paths::installed_bundles_file().exists());
|
assert!(!paths::installed_bundles_file().exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_silently_refreshes_owned_unmodified_files() {
|
||||||
|
use crate::config::bundles::hash_bytes;
|
||||||
|
|
||||||
|
if *IS_STDOUT_TERMINAL {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping update_silently_refreshes_owned_unmodified_files: requires non-TTY stdout"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-refresh");
|
||||||
|
let src_root = fresh_temp_dir("upd-refresh-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: refresh-bundle\n");
|
||||||
|
write_src(&repo, "macros/hello.yaml", "v1\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
commit_file(&repo, "macros/hello.yaml", "v2\n");
|
||||||
|
|
||||||
|
update_bundle("refresh-bundle").unwrap();
|
||||||
|
|
||||||
|
let installed = paths::macros_dir().join("hello.yaml");
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), "v2\n");
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let files = &store.get("refresh-bundle").unwrap().files;
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0].sha256, hash_bytes(b"v2\n"));
|
||||||
|
assert_eq!(files[0].action, FileAction::Replaced);
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_plan_keep_all_preserves_local_edit_and_stale_record() {
|
||||||
|
let dir = fresh_temp_dir("upd-keep-modified-");
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/owned.yaml");
|
||||||
|
write_src(&dir, "macros/owned.yaml", "installed content");
|
||||||
|
let recorded_sha = hash_file(&dst).unwrap();
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: provenance_path(&dst),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: recorded_sha.clone(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&dst, "local edit").unwrap();
|
||||||
|
let src = dir.join("upstream/owned.yaml");
|
||||||
|
write_src(&dir, "upstream/owned.yaml", "upstream content");
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![PlannedFile {
|
||||||
|
src,
|
||||||
|
dst: dst.clone(),
|
||||||
|
kind: PlannedKind::Conflict,
|
||||||
|
top_category: TopCategory::Macros,
|
||||||
|
}],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_plan(&plan, StickyMode::KeepAll, &mut store, "omc").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&dst).unwrap(), "local edit");
|
||||||
|
let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
let files = &reloaded.get("omc").unwrap().files;
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0].sha256, recorded_sha);
|
||||||
|
assert_ne!(files[0].sha256, hash_file(&dst).unwrap());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_plan_replace_all_takes_upstream_and_rerecords_hash() {
|
||||||
|
use crate::config::bundles::hash_bytes;
|
||||||
|
|
||||||
|
let dir = fresh_temp_dir("upd-replace-modified-");
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/owned.yaml");
|
||||||
|
write_src(&dir, "macros/owned.yaml", "installed content");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: provenance_path(&dst),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: hash_file(&dst).unwrap(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&dst, "local edit").unwrap();
|
||||||
|
let src = dir.join("upstream/owned.yaml");
|
||||||
|
write_src(&dir, "upstream/owned.yaml", "upstream content");
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![PlannedFile {
|
||||||
|
src,
|
||||||
|
dst: dst.clone(),
|
||||||
|
kind: PlannedKind::Conflict,
|
||||||
|
top_category: TopCategory::Macros,
|
||||||
|
}],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "omc").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&dst).unwrap(), "upstream content");
|
||||||
|
let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
let files = &reloaded.get("omc").unwrap().files;
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0].sha256, hash_bytes(b"upstream content"));
|
||||||
|
assert_eq!(files[0].action, FileAction::Replaced);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_bails_non_interactively_on_unowned_conflict() {
|
||||||
|
if *IS_STDOUT_TERMINAL {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping update_bails_non_interactively_on_unowned_conflict: requires non-TTY stdout"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-unowned");
|
||||||
|
let src_root = fresh_temp_dir("upd-unowned-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: unowned-bundle\n");
|
||||||
|
write_src(&repo, "macros/a.yaml", "a\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
commit_file(&repo, "macros/user.yaml", "upstream\n");
|
||||||
|
fs::create_dir_all(paths::macros_dir()).unwrap();
|
||||||
|
fs::write(paths::macros_dir().join("user.yaml"), "local\n").unwrap();
|
||||||
|
|
||||||
|
let err = update_bundle("unowned-bundle").unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("Refusing to overwrite"),
|
||||||
|
"got: {err}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(paths::macros_dir().join("user.yaml")).unwrap(),
|
||||||
|
"local\n"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_plan_replace_all_transfers_ownership_between_bundles() {
|
||||||
|
let dir = fresh_temp_dir("upd-transfer-");
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("alpha", test_metadata("https://github.com/a/alpha"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("beta", test_metadata("https://github.com/b/beta"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/shared.yaml");
|
||||||
|
write_src(&dir, "macros/shared.yaml", "alpha content");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"alpha",
|
||||||
|
FileRecord {
|
||||||
|
path: provenance_path(&dst),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: hash_file(&dst).unwrap(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let src = dir.join("beta-src/shared.yaml");
|
||||||
|
write_src(&dir, "beta-src/shared.yaml", "beta content");
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![PlannedFile {
|
||||||
|
src,
|
||||||
|
dst: dst.clone(),
|
||||||
|
kind: PlannedKind::Conflict,
|
||||||
|
top_category: TopCategory::Macros,
|
||||||
|
}],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "beta").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&dst).unwrap(), "beta content");
|
||||||
|
let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
assert!(reloaded.get("alpha").unwrap().files.is_empty());
|
||||||
|
let beta_files = &reloaded.get("beta").unwrap().files;
|
||||||
|
assert_eq!(beta_files.len(), 1);
|
||||||
|
assert_eq!(beta_files[0].path, provenance_path(&dst));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_keeps_obsolete_files_non_interactively() {
|
||||||
|
if *IS_STDOUT_TERMINAL {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping update_keeps_obsolete_files_non_interactively: requires non-TTY stdout"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-keep");
|
||||||
|
let src_root = fresh_temp_dir("upd-obsolete-keep-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: obs-keep\n");
|
||||||
|
write_src(&repo, "macros/keep.yaml", "k\n");
|
||||||
|
write_src(&repo, "macros/gone.yaml", "g\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
fs::remove_file(repo.join("macros/gone.yaml")).unwrap();
|
||||||
|
commit_file(&repo, "macros/keep.yaml", "k2\n");
|
||||||
|
|
||||||
|
update_bundle("obs-keep").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(),
|
||||||
|
"g\n"
|
||||||
|
);
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let record = store.get("obs-keep").unwrap();
|
||||||
|
assert!(record.files.iter().any(|f| f.path == "macros/gone.yaml"));
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_obsolete_delete_removes_file_and_record() {
|
||||||
|
let dir = fresh_temp_dir("upd-obsolete-delete-");
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/gone.yaml");
|
||||||
|
write_src(&dir, "macros/gone.yaml", "g");
|
||||||
|
let path = provenance_path(&dst);
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: path.clone(),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: hash_file(&dst).unwrap(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
apply_obsolete_action(&mut store, "omc", &path, &dst, ObsoleteAction::Delete).unwrap();
|
||||||
|
|
||||||
|
assert!(!dst.exists());
|
||||||
|
let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
assert!(reloaded.get("omc").unwrap().files.is_empty());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_obsolete_drops_records_for_missing_files() {
|
||||||
|
let dir = fresh_temp_dir("upd-obsolete-missing-");
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let ghost = dir.join("macros/ghost.yaml");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: provenance_path(&ghost),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: Vec::new(),
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, "omc", &plan).unwrap();
|
||||||
|
|
||||||
|
assert!(store.get("omc").unwrap().files.is_empty());
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_refreshes_record_metadata_and_stamps_updated_at() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-meta");
|
||||||
|
let src_root = fresh_temp_dir("upd-meta-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(
|
||||||
|
&repo,
|
||||||
|
BUNDLE_MANIFEST_FILE,
|
||||||
|
"name: meta-bundle\n\
|
||||||
|
version: \"1.0\"\n\
|
||||||
|
description: Old\n\
|
||||||
|
homepage: https://example.com/old\n",
|
||||||
|
);
|
||||||
|
write_src(&repo, "macros/m.yaml", "a: 1\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
let installed_at = BundleStore::load()
|
||||||
|
.unwrap()
|
||||||
|
.get("meta-bundle")
|
||||||
|
.unwrap()
|
||||||
|
.installed_at
|
||||||
|
.clone();
|
||||||
|
let new_sha = commit_file(
|
||||||
|
&repo,
|
||||||
|
BUNDLE_MANIFEST_FILE,
|
||||||
|
"name: meta-bundle\n\
|
||||||
|
version: \"2.0\"\n\
|
||||||
|
description: New\n\
|
||||||
|
homepage: https://example.com/new\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
update_bundle("meta-bundle").unwrap();
|
||||||
|
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let record = store.get("meta-bundle").unwrap();
|
||||||
|
assert_eq!(record.commit, new_sha);
|
||||||
|
assert_eq!(record.version.as_deref(), Some("2.0"));
|
||||||
|
assert_eq!(record.description.as_deref(), Some("New"));
|
||||||
|
assert_eq!(record.homepage.as_deref(), Some("https://example.com/new"));
|
||||||
|
assert!(record.updated_at.is_some());
|
||||||
|
assert_eq!(record.installed_at, installed_at);
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_unknown_bundle_lists_installed_names() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-unknown-name");
|
||||||
|
|
||||||
|
let err = update_bundle("nope").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("none are installed"), "got: {err}");
|
||||||
|
|
||||||
|
let src_root = fresh_temp_dir("upd-unknown-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n");
|
||||||
|
write_src(&repo, "macros/m.yaml", "a: 1\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
|
||||||
|
let err = update_bundle("nope").unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("installed bundles: known-bundle"),
|
||||||
|
"got: {err}"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_honors_recorded_sha_pin() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-pin");
|
||||||
|
let src_root = fresh_temp_dir("upd-pin-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: pin-bundle\n");
|
||||||
|
write_src(&repo, "macros/one.yaml", "1\n");
|
||||||
|
let pinned = init_bundle_repo(&repo);
|
||||||
|
install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap();
|
||||||
|
let newer = commit_file(&repo, "macros/two.yaml", "2\n");
|
||||||
|
assert_ne!(pinned, newer);
|
||||||
|
|
||||||
|
update_bundle("pin-bundle").unwrap();
|
||||||
|
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let record = store.get("pin-bundle").unwrap();
|
||||||
|
assert_eq!(record.commit, pinned);
|
||||||
|
assert_eq!(record.git_ref.as_deref(), Some(pinned.as_str()));
|
||||||
|
assert!(!paths::macros_dir().join("two.yaml").exists());
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_ref_override_moves_the_pin() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-move-pin");
|
||||||
|
let src_root = fresh_temp_dir("upd-move-pin-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: move-bundle\n");
|
||||||
|
write_src(&repo, "macros/one.yaml", "1\n");
|
||||||
|
let pinned = init_bundle_repo(&repo);
|
||||||
|
install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap();
|
||||||
|
let newer = commit_file(&repo, "macros/two.yaml", "2\n");
|
||||||
|
|
||||||
|
update_bundle(&format!("move-bundle#{newer}")).unwrap();
|
||||||
|
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let record = store.get("move-bundle").unwrap();
|
||||||
|
assert_eq!(record.commit, newer);
|
||||||
|
assert_eq!(record.git_ref.as_deref(), Some(newer.as_str()));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(paths::macros_dir().join("two.yaml")).unwrap(),
|
||||||
|
"2\n"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ pub use self::app_config::AppConfig;
|
|||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
pub use self::bundles::list_installed_bundles;
|
pub use self::bundles::list_installed_bundles;
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
pub use self::install_remote::{install_remote, install_remote_from_repl_args, update_bundle};
|
||||||
pub use self::macro_policy::{
|
pub use self::macro_policy::{
|
||||||
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ async fn main() -> Result<()> {
|
|||||||
return config::install_remote(url, cli.filter, cli.install_force);
|
return config::install_remote(url, cli.filter, cli.install_force);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(spec) = cli.update_bundle.as_deref() {
|
||||||
|
return config::update_bundle(spec);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(client_arg) = &cli.authenticate {
|
if let Some(client_arg) = &cli.authenticate {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user