From 73a994ac53d355dad731df0af39e6bb14d064cb3 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sat, 28 Feb 2026 02:13:46 -0600 Subject: [PATCH] feat(source): support cherry-picking commits for git sources - add source.cherry_pick (and cherry-pick alias) to package spec parsing/serialization - apply listed cherry-picks in order after git checkout - reject cherry_pick on non-git sources with explicit error - include cherry_pick in interactive spec TOML output - add parsing/behavior tests and update Source test fixtures --- src/builder/bin.rs | 1 + src/builder/custom.rs | 1 + src/builder/makefile.rs | 1 + src/builder/meson.rs | 1 + src/builder/mod.rs | 1 + src/builder/python.rs | 1 + src/db/mod.rs | 1 + src/deps.rs | 3 + src/install/scripts.rs | 1 + src/package/interactive.rs | 18 ++++++ src/package/spec.rs | 48 +++++++++++++++ src/planner.rs | 1 + src/source/git.rs | 118 ++++++++++++++++++++++++++++++++++++- src/source/hooks.rs | 1 + src/source/mod.rs | 33 +++++++++++ 15 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/builder/bin.rs b/src/builder/bin.rs index 9473f0c..b812ede 100644 --- a/src/builder/bin.rs +++ b/src/builder/bin.rs @@ -81,6 +81,7 @@ mod tests { extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Bin, diff --git a/src/builder/custom.rs b/src/builder/custom.rs index 67a3ce4..49e2015 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -283,6 +283,7 @@ mod tests { extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index 8340897..c0438f1 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -132,6 +132,7 @@ mod tests { extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Makefile, diff --git a/src/builder/meson.rs b/src/builder/meson.rs index 893509c..c750057 100755 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -323,6 +323,7 @@ mod tests { extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Meson, diff --git a/src/builder/mod.rs b/src/builder/mod.rs index ae16a75..50dd73f 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -267,6 +267,7 @@ mod tests { extract_dir: "src".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, diff --git a/src/builder/python.rs b/src/builder/python.rs index 6d4fc01..4bd09ea 100644 --- a/src/builder/python.rs +++ b/src/builder/python.rs @@ -721,6 +721,7 @@ mod tests { extract_dir: "src".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Python, diff --git a/src/db/mod.rs b/src/db/mod.rs index a9a2591..412ec39 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -490,6 +490,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, diff --git a/src/deps.rs b/src/deps.rs index 00ee556..e87a83f 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -375,6 +375,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: crate::package::Build { build_type: crate::package::BuildType::Custom, @@ -415,6 +416,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: crate::package::Build { build_type: crate::package::BuildType::Custom, @@ -463,6 +465,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: crate::package::Build { build_type: crate::package::BuildType::Custom, diff --git a/src/install/scripts.rs b/src/install/scripts.rs index 179a216..f4d5bb2 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -636,6 +636,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 13fbcd2..15412e4 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -425,6 +425,7 @@ pub fn create_interactive() -> Result { extract_dir, patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }; if show_advanced { @@ -791,6 +792,17 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !s.cherry_pick.is_empty() { + st.insert( + "cherry_pick".into(), + Value::Array( + s.cherry_pick + .iter() + .map(|rev| Value::String(rev.clone())) + .collect(), + ), + ); + } arr.push(Value::Table(st)); } root.insert("source".into(), Value::Array(arr)); @@ -1316,6 +1328,7 @@ mod tests { extract_dir: "foo-1.0".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, @@ -1365,6 +1378,7 @@ mod tests { extract_dir: "foo-1.0".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, @@ -1401,6 +1415,7 @@ mod tests { extract_dir: "foo-1.0".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, @@ -1464,6 +1479,7 @@ mod tests { extract_dir: "foo-1.0".into(), patches: vec!["fix.patch".into()], post_extract: vec!["autoreconf -fi".into()], + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, @@ -1525,6 +1541,7 @@ mod tests { extract_dir: "$name-$version".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, @@ -1560,6 +1577,7 @@ mod tests { extract_dir: "foo-1.0".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, diff --git a/src/package/spec.rs b/src/package/spec.rs index 58ac62e..59ae6d7 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -1153,6 +1153,7 @@ type = "custom" ); assert!(spec.sources()[0].patches.is_empty()); assert!(spec.sources()[0].post_extract.is_empty()); + assert!(spec.sources()[0].cherry_pick.is_empty()); assert_eq!(spec.spec_dir, tmp.path()); } @@ -1193,6 +1194,40 @@ type = "custom" assert_eq!(spec.sources()[1].extract_dir, "bar"); } + #[test] + fn parse_git_source_with_cherry_pick() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.git#main" +sha256 = "skip" +extract_dir = "foo" +cherry_pick = ["deadbeef", "cafebabe"] + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.sources()[0].cherry_pick, + vec!["deadbeef".to_string(), "cafebabe".to_string()] + ); + } + #[test] fn parse_package_dependencies_overrides() { let tmp = tempfile::tempdir().unwrap(); @@ -2378,6 +2413,7 @@ type = "custom" extract_dir: "e".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, @@ -2503,6 +2539,18 @@ pub struct Source { /// post_extract = ["autoreconf -fi"] #[serde(default)] pub post_extract: Vec, + + /// Optional list of git commit hashes/revs to cherry-pick after checkout. + /// + /// This is only valid for git sources (`*.git` URL or `url#rev` git form). + /// Example: + /// cherry_pick = ["a1b2c3d4", "deadbeef"] + #[serde( + default, + alias = "cherry-pick", + deserialize_with = "deserialize_string_or_array" + )] + pub cherry_pick: Vec, } /// Manual source copied before standard source fetching. diff --git a/src/planner.rs b/src/planner.rs index d453746..936d1fd 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -746,6 +746,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, diff --git a/src/source/git.rs b/src/source/git.rs index e15ffa1..19007c2 100755 --- a/src/source/git.rs +++ b/src/source/git.rs @@ -20,6 +20,7 @@ pub fn checkout( checkout_dir: &Path, git_cache_dir: &Path, pkgname: &str, + cherry_pick_revs: &[String], ) -> Result<()> { fs::create_dir_all(git_cache_dir).with_context(|| { format!( @@ -51,6 +52,75 @@ pub fn checkout( let repo = Repository::open(checkout_dir)?; checkout_rev(&repo, rev).with_context(|| format!("Failed to checkout revision '{}'", rev))?; + apply_cherry_picks(&repo, cherry_pick_revs)?; + + Ok(()) +} + +fn apply_cherry_picks(repo: &Repository, cherry_pick_revs: &[String]) -> Result<()> { + if cherry_pick_revs.is_empty() { + return Ok(()); + } + + crate::log_info!("Applying {} git cherry-pick(s)...", cherry_pick_revs.len()); + + for rev in cherry_pick_revs { + let rev = rev.trim(); + if rev.is_empty() { + anyhow::bail!("Encountered empty entry in source.cherry_pick"); + } + + let obj = resolve_rev_object(repo, rev) + .with_context(|| format!("Could not resolve cherry-pick rev: {}", rev))?; + let commit = obj + .peel_to_commit() + .with_context(|| format!("Could not peel cherry-pick rev to commit: {}", rev))?; + repo.cherrypick(&commit, None) + .with_context(|| format!("Failed to cherry-pick rev: {}", rev))?; + + let parent = repo + .head() + .with_context(|| format!("Failed to read HEAD during cherry-pick {}", rev))? + .peel_to_commit() + .with_context(|| format!("Failed to peel HEAD to commit during {}", rev))?; + + let mut index = repo + .index() + .with_context(|| format!("Failed to open index after cherry-pick {}", rev))?; + if index.has_conflicts() { + anyhow::bail!("Cherry-pick produced conflicts for rev {}", rev); + } + + let tree_id = index + .write_tree() + .with_context(|| format!("Failed to write tree after cherry-pick {}", rev))?; + let tree = repo + .find_tree(tree_id) + .with_context(|| format!("Failed to find tree after cherry-pick {}", rev))?; + let message = commit.summary().unwrap_or("cherry-pick"); + let new_head = repo + .commit( + None, + &commit.author(), + &commit.committer(), + message, + &tree, + &[&parent], + ) + .with_context(|| format!("Failed to create cherry-pick commit for rev {}", rev))?; + repo.set_head_detached(new_head) + .with_context(|| format!("Failed to update detached HEAD after cherry-pick {}", rev))?; + + let mut checkout = git2::build::CheckoutBuilder::new(); + checkout.force(); + let obj = repo + .find_object(new_head, None) + .with_context(|| format!("Failed to resolve new HEAD after cherry-pick {}", rev))?; + repo.checkout_tree(&obj, Some(&mut checkout)) + .with_context(|| format!("Failed to update worktree after cherry-pick {}", rev))?; + + crate::log_info!(" cherry_pick: {}", rev); + } Ok(()) } @@ -352,8 +422,15 @@ mod tests { let tree_id = index.write_tree().unwrap(); let tree = repo.find_tree(tree_id).unwrap(); let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); + let mut parents = Vec::new(); + if let Ok(head) = repo.head() + && let Some(oid) = head.target() + { + parents.push(repo.find_commit(oid).unwrap()); + } + let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &[]) + repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) .unwrap() } @@ -404,4 +481,43 @@ mod tests { checkout_rev(&repo, "feature").unwrap(); assert_eq!(repo.head().unwrap().target().unwrap(), commit_oid); } + + #[test] + fn apply_cherry_picks_applies_commit_in_order() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let base = commit_file(&repo, &workdir, "README", "base"); + let picked = commit_file(&repo, &workdir, "README", "picked"); + + checkout_rev(&repo, &base.to_string()).unwrap(); + apply_cherry_picks(&repo, &[picked.to_string()]).unwrap(); + + let head = repo.head().unwrap().target().unwrap(); + assert_eq!(head, picked); + assert_eq!( + std::fs::read_to_string(workdir.join("README")).unwrap(), + "picked" + ); + } + + #[test] + fn apply_cherry_picks_errors_for_unknown_rev() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let base = commit_file(&repo, &workdir, "README", "base"); + checkout_rev(&repo, &base.to_string()).unwrap(); + + let err = apply_cherry_picks(&repo, &["deadbeef".to_string()]) + .expect_err("unknown cherry-pick rev should fail"); + assert!( + err.to_string() + .contains("Could not resolve cherry-pick rev") + ); + } } diff --git a/src/source/hooks.rs b/src/source/hooks.rs index 6cbdc54..5868fe8 100755 --- a/src/source/hooks.rs +++ b/src/source/hooks.rs @@ -310,6 +310,7 @@ mod tests { extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, diff --git a/src/source/mod.rs b/src/source/mod.rs index c360c88..68735b1 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -116,6 +116,7 @@ pub fn copy_manual_sources(spec: &PackageSpec, cache_dir: &Path, build_dir: &Pat extract_dir: "manual-source".to_string(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }; let fetched = fetcher::fetch_archive(spec, &source, &cache_dir.join("manual"))?; @@ -299,6 +300,11 @@ fn prepare_one( ) -> Result { let url = spec.expand_vars(&source.url); let extract_dir_name = spec.expand_vars(&source.extract_dir); + let cherry_pick_revs: Vec = source + .cherry_pick + .iter() + .map(|rev| spec.expand_vars(rev)) + .collect(); // Local file:// handling (directories or archives) if let Some(path_str) = url.strip_prefix("file://") { @@ -358,11 +364,19 @@ fn prepare_one( &checkout_dir, &cache_dir.join("git"), &spec.package.name, + &cherry_pick_revs, )?; hooks::post_extract(spec, source, &checkout_dir, cache_dir)?; return Ok(checkout_dir); } + if !source.cherry_pick.is_empty() { + bail!( + "source.cherry_pick is only supported for git sources (got URL: {})", + source.url + ); + } + let src_dir = build_dir.join(&extract_dir_name); if src_dir.exists() && src_dir.join(".depot_state").exists() { crate::log_info!( @@ -458,6 +472,7 @@ mod tests { extract_dir: "src".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, @@ -517,6 +532,7 @@ mod tests { extract_dir: "json-$version".into(), patches: Vec::new(), post_extract: Vec::new(), + cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, @@ -539,6 +555,23 @@ mod tests { assert_eq!(rev, "0123456789abcdef"); } + #[test] + fn prepare_one_rejects_cherry_pick_for_non_git_sources() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + let mut spec = mk_spec_with_manuals(PathBuf::from("."), Vec::new()); + spec.source[0].url = "https://example.com/foo.tar.gz".into(); + spec.source[0].cherry_pick = vec!["deadbeef".into()]; + + let err = prepare_one(&spec, &spec.source[0], &cache_dir, &build_dir) + .expect_err("non-git source with cherry_pick must be rejected"); + assert!( + err.to_string() + .contains("source.cherry_pick is only supported for git sources") + ); + } + #[test] fn verify_file_hash_accepts_multiple_algorithms() { use sha2::{Digest, Sha256, Sha512};