@@ -26,9 +26,9 @@ struct ProductVersion {
2626
2727#[ derive( Deserialize , Serialize ) ]
2828struct ProductVersionConfig {
29- upstream : String ,
30- #[ serde( with = "utils::oid_serde " ) ]
31- base : Oid ,
29+ upstream : Option < String > ,
30+ #[ serde( default , with = "utils::option_oid_serde " ) ]
31+ base : Option < Oid > ,
3232 mirror : Option < String > ,
3333}
3434
@@ -39,15 +39,77 @@ struct ProductVersionContext {
3939
4040impl ProductVersionContext {
4141 fn load_config ( & self ) -> Result < ProductVersionConfig > {
42- let path = & self . config_path ( ) ;
43- tracing:: info!(
44- config. path = ?path,
45- "loading config"
46- ) ;
47- toml:: from_str :: < ProductVersionConfig > (
48- & std:: fs:: read_to_string ( path) . context ( LoadConfigSnafu { path } ) ?,
49- )
50- . context ( ParseConfigSnafu { path } )
42+ let version_config_path = & self . version_config_path ( ) ;
43+ let product_config_path = & self . product_config_path ( ) ;
44+
45+ // Load product-level config (required)
46+ let product_config: ProductVersionConfig = {
47+ tracing:: info!(
48+ config. path = ?product_config_path,
49+ "loading product-level config"
50+ ) ;
51+ toml:: from_str :: < ProductVersionConfig > (
52+ & std:: fs:: read_to_string ( product_config_path) . context ( LoadConfigSnafu {
53+ path : product_config_path,
54+ } ) ?,
55+ )
56+ . context ( ParseConfigSnafu {
57+ path : product_config_path,
58+ } ) ?
59+ } ;
60+
61+ // Load version-level config (optional)
62+ let version_config: Option < ProductVersionConfig > =
63+ match std:: fs:: read_to_string ( version_config_path) {
64+ Ok ( s) => {
65+ tracing:: info!(
66+ config. path = ?version_config_path,
67+ "loading version-level config"
68+ ) ;
69+ Some (
70+ toml:: from_str :: < ProductVersionConfig > ( & s) . context ( ParseConfigSnafu {
71+ path : version_config_path,
72+ } ) ?,
73+ )
74+ }
75+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => None ,
76+ Err ( e) => {
77+ return Err ( e) . context ( LoadConfigSnafu {
78+ path : version_config_path,
79+ } )
80+ }
81+ } ;
82+ // Merge: version-level overrides product-level, validate required fields afterwards
83+ let merged = ProductVersionConfig {
84+ upstream : version_config
85+ . as_ref ( )
86+ . and_then ( |c| c. upstream . clone ( ) )
87+ . or ( product_config. upstream . clone ( ) )
88+ . or_else ( || {
89+ Err ( ValidateConfigSnafu {
90+ reason : "upstream is required" . to_string ( ) ,
91+ paths : vec ! [ product_config_path, version_config_path] ,
92+ } )
93+ . ok ( )
94+ } ) ,
95+ base : version_config
96+ . as_ref ( )
97+ . and_then ( |c| c. base )
98+ . or ( product_config. base )
99+ . or_else ( || {
100+ Err ( ValidateConfigSnafu {
101+ reason : "base commit is required" . to_string ( ) ,
102+ paths : vec ! [ product_config_path, version_config_path] ,
103+ } )
104+ . ok ( )
105+ } ) ,
106+ mirror : version_config
107+ . as_ref ( )
108+ . and_then ( |c| c. mirror . clone ( ) )
109+ . or ( product_config. mirror . clone ( ) ) ,
110+ } ;
111+
112+ Ok ( merged)
51113 }
52114
53115 /// The root directory for files related to the product (across all versions).
@@ -63,10 +125,15 @@ impl ProductVersionContext {
63125 }
64126
65127 /// The patchable configuration file for the product version.
66- fn config_path ( & self ) -> PathBuf {
128+ fn version_config_path ( & self ) -> PathBuf {
67129 self . patch_dir ( ) . join ( "patchable.toml" )
68130 }
69131
132+ /// The product-level patchable configuration file
133+ fn product_config_path ( & self ) -> PathBuf {
134+ self . product_dir ( ) . join ( "stackable/patches/patchable.toml" )
135+ }
136+
70137 /// The directory containing all ephemeral data used by patchable for the product (across all versions).
71138 ///
72139 /// Should be gitignored, and can safely be deleted as long as all relevant versions have been `patchable export`ed.
@@ -141,20 +208,11 @@ enum Cmd {
141208 #[ clap( flatten) ]
142209 pv : ProductVersion ,
143210
144- /// The upstream URL (such as https://github.com/apache/druid.git)
145- #[ clap( long) ]
146- upstream : String ,
147-
148211 /// The upstream commit-ish (such as druid-28.0.0) that the patch series applies to
149212 ///
150213 /// Refs (such as tags and branches) will be resolved to commit IDs.
151214 #[ clap( long) ]
152215 base : String ,
153-
154- /// Assume a mirror exists at stackabletech/<repo_name> and push the base ref to it.
155- /// The mirror URL will be stored in patchable.toml, and used instead of the original upstream.
156- #[ clap( long) ]
157- mirror : bool ,
158216 } ,
159217
160218 /// Shows the patch directory for a given product version
@@ -190,6 +248,8 @@ pub enum Error {
190248 source : toml:: de:: Error ,
191249 path : PathBuf ,
192250 } ,
251+ #[ snafu( display( "failed to validate config: {reason:?} (used config files: {paths:?})" ) ) ]
252+ ValidateConfig { reason : String , paths : Vec < PathBuf > } ,
193253 #[ snafu( display( "failed to serialize config" ) ) ]
194254 SerializeConfig { source : toml:: ser:: Error } ,
195255 #[ snafu( display( "failed to create patch dir at {path:?}" ) ) ]
@@ -203,8 +263,6 @@ pub enum Error {
203263 path : PathBuf ,
204264 } ,
205265
206- #[ snafu( display( "failed to parse upstream URL {url:?} to extract repository name" ) ) ]
207- ParseUpstreamUrl { url : String } ,
208266 #[ snafu( display( "failed to add temporary mirror remote for {url:?}" ) ) ]
209267 AddMirrorRemote { source : git2:: Error , url : String } ,
210268 #[ snafu( display( "failed to push commit {commit} (as {refspec}) to mirror {url:?}" ) ) ]
@@ -302,10 +360,14 @@ fn main() -> Result<()> {
302360 let product_repo = repo:: ensure_bare_repo ( & product_repo_root)
303361 . context ( OpenProductRepoForCheckoutSnafu ) ?;
304362
363+ // Presence of `base` and `upstream` already validated by load_config, so unwrap is safe here
305364 let base_commit = repo:: resolve_and_fetch_commitish (
306365 & product_repo,
307- & config. base . to_string ( ) ,
308- config. mirror . as_deref ( ) . unwrap_or ( & config. upstream ) ,
366+ & config. base . unwrap ( ) . to_string ( ) ,
367+ config
368+ . mirror
369+ . as_deref ( )
370+ . unwrap_or ( config. upstream . as_ref ( ) . unwrap ( ) ) ,
309371 )
310372 . context ( FetchBaseCommitSnafu ) ?;
311373 let base_branch = ctx. base_branch ( ) ;
@@ -378,7 +440,8 @@ fn main() -> Result<()> {
378440 path : product_worktree_root,
379441 } ) ?;
380442
381- let base_commit = config. base ;
443+ // Presence of `base` already validated by load_config, so unwrap is safe here
444+ let base_commit = config. base . unwrap ( ) ;
382445 let original_leaf_commit = product_version_repo
383446 . head ( )
384447 . and_then ( |c| c. peel_to_commit ( ) )
@@ -417,9 +480,7 @@ fn main() -> Result<()> {
417480
418481 Cmd :: Init {
419482 pv,
420- upstream,
421483 base,
422- mirror,
423484 } => {
424485 let ctx = ProductVersionContext {
425486 pv,
@@ -434,77 +495,75 @@ fn main() -> Result<()> {
434495 . in_scope ( || repo:: ensure_bare_repo ( & product_repo_root) )
435496 . context ( OpenProductRepoForCheckoutSnafu ) ?;
436497
498+ let config = ctx. load_config ( ) ?;
499+ // Presence of `upstream` already validated by load_config, so unwrap is safe here
500+ let upstream = config. upstream . unwrap ( ) ;
501+
437502 // --base can be a reference, but patchable.toml should always have a resolved commit id,
438503 // so that it cannot be changed under our feet (without us knowing so, anyway...).
439504 tracing:: info!( ?base, "resolving base commit-ish" ) ;
440- let base_commit = repo:: resolve_and_fetch_commitish ( & product_repo, & base, & upstream) . context ( FetchBaseCommitSnafu ) ?;
441- let mut upstream_mirror = None ;
442-
443- if mirror {
444- // Parse e.g. "https://github.com/apache/druid.git" into "druid"
445- let repo_name = upstream. split ( '/' ) . last ( ) . map ( |repo| repo. trim_end_matches ( ".git" ) ) . filter ( |name| !name. is_empty ( ) ) . context ( ParseUpstreamUrlSnafu { url : & upstream } ) ?;
446-
447- let mirror_url = format ! ( "https://github.com/stackabletech/{repo_name}.git" ) ;
448- tracing:: info!( mirror_url, "using mirror repository" ) ;
505+ let base_commit = repo:: resolve_and_fetch_commitish ( & product_repo, & base, & upstream)
506+ . context ( FetchBaseCommitSnafu ) ?;
449507
508+ if let Some ( mirror_url) = config. mirror {
450509 // Add mirror remote
451- let mut mirror_remote = product_repo
452- . remote_anonymous ( & mirror_url)
453- . context ( AddMirrorRemoteSnafu { url : mirror_url. clone ( ) } ) ?;
510+ let mut mirror_remote =
511+ product_repo
512+ . remote_anonymous ( & mirror_url)
513+ . context ( AddMirrorRemoteSnafu {
514+ url : mirror_url. clone ( ) ,
515+ } ) ?;
454516
455517 // Push the base commit to the mirror
456518 tracing:: info!( commit = %base_commit, base = base, url = mirror_url, "pushing commit to mirror" ) ;
457519 let mut callbacks = git2:: RemoteCallbacks :: new ( ) ;
458520 callbacks. credentials ( |url, username_from_url, _allowed_types| {
459521 git2:: Cred :: credential_helper (
460- & git2:: Config :: open_default ( ) . unwrap ( ) , // Use default git config
522+ & git2:: Config :: open_default ( )
523+ . expect ( "failed to open default Git configuration" ) , // Use default git config,
461524 url,
462525 username_from_url,
463526 )
464527 } ) ;
465528
466529 // Add progress tracking for push operation
467- let ( span_push, mut quant_push) = utils:: setup_progress_tracking ( tracing:: info_span!( "pushing" ) ) ;
530+ let ( span_push, mut quant_push) =
531+ utils:: setup_progress_tracking ( tracing:: info_span!( "pushing" ) ) ;
468532 let _ = span_push. enter ( ) ;
469533
470534 callbacks. push_transfer_progress ( move |current, total, _| {
471535 if total > 0 {
472- quant_push. update_span_progress (
473- current,
474- total,
475- & span_push,
476- ) ;
536+ quant_push. update_span_progress ( current, total, & span_push) ;
477537 }
478538 } ) ;
479539
480540 let mut push_options = git2:: PushOptions :: new ( ) ;
481541 push_options. remote_callbacks ( callbacks) ;
482542
543+ // Always push the commit as a Git tag named like the value of `base`
483544 let refspec = format ! ( "{base_commit}:refs/tags/{base}" ) ;
484545 tracing:: info!( refspec, "constructed push refspec" ) ;
485546
486547 mirror_remote
487548 . push ( & [ & refspec] , Some ( & mut push_options) )
488549 . context ( PushToMirrorSnafu {
489- url : & mirror_url,
550+ url : mirror_url. clone ( ) ,
490551 refspec : & refspec,
491552 commit : base_commit,
492553 } ) ?;
493554
494555 tracing:: info!( "successfully pushed base ref to mirror" ) ;
495-
496- upstream_mirror = Some ( mirror_url) ;
497556 } ;
498557
499558 tracing:: info!( ?base, base. commit = ?base_commit, "resolved base commit" ) ;
500559
501- tracing:: info!( "saving configuration" ) ;
560+ tracing:: info!( "saving version-level configuration" ) ;
502561 let config = ProductVersionConfig {
503- upstream,
504- mirror : upstream_mirror ,
505- base : base_commit,
562+ upstream : None ,
563+ mirror : None ,
564+ base : Some ( base_commit) ,
506565 } ;
507- let config_path = ctx. config_path ( ) ;
566+ let config_path = ctx. version_config_path ( ) ;
508567 if let Some ( config_dir) = config_path. parent ( ) {
509568 std:: fs:: create_dir_all ( config_dir)
510569 . context ( CreatePatchDirSnafu { path : config_dir } ) ?;
0 commit comments