@@ -21,7 +21,7 @@ pub enum RepoAction {
2121 Audit ( AuditReposCommand ) ,
2222 /// Interactively fix missing description, topics, and license
2323 Fix ( FixReposCommand ) ,
24- /// Push files from a local directory to all matching repositories
24+ /// Sync files from a local directory or the blueprint repository
2525 Sync ( SyncReposCommand ) ,
2626 /// Create a new repository from a named blueprint
2727 Blueprint ( BlueprintRepoCommand ) ,
@@ -44,6 +44,22 @@ pub struct SyncReposCommand {
4444 #[ arg( long, default_value = ".github" ) ]
4545 pub source : String ,
4646
47+ /// Sync files from the blueprint source repository instead of local files.
48+ #[ arg( long, default_value_t = false ) ]
49+ pub from_blueprint : bool ,
50+
51+ /// Override the blueprint source repo (org/repo or GitHub URL).
52+ #[ arg( long) ]
53+ pub blueprint_repo : Option < String > ,
54+
55+ /// Override the blueprint source branch (default: main or config default_branch).
56+ #[ arg( long) ]
57+ pub blueprint_branch : Option < String > ,
58+
59+ /// Paths to sync from the blueprint repo (repeatable, defaults to .github).
60+ #[ arg( long, value_name = "PATH" ) ]
61+ pub blueprint_path : Vec < String > ,
62+
4763 #[ arg( long, help = "Only sync to repositories matching this template (by topic overlap)" ) ]
4864 pub template : Option < String > ,
4965}
@@ -103,19 +119,42 @@ impl RepoCommand {
103119 fix_repos ( provider. as_ref ( ) , & config) . await ?;
104120 }
105121 RepoAction :: Sync ( command) => {
106- let source_path = std:: path:: Path :: new ( & command. source ) ;
107- if !source_path. exists ( ) {
108- anyhow:: bail!(
109- "sync source directory '{}' does not exist" ,
110- command. source
111- ) ;
112- }
122+ let use_blueprint = command. from_blueprint
123+ || command. blueprint_repo . is_some ( )
124+ || !command. blueprint_path . is_empty ( ) ;
125+
126+ let files = if use_blueprint {
127+ let blueprint = resolve_blueprint_source ( config, command) ?;
128+ let ( owner, repo) = parse_repo_slug ( & blueprint. repo ) ?;
129+ let files = provider
130+ . fetch_repository_files (
131+ & owner,
132+ & repo,
133+ & blueprint. branch ,
134+ & blueprint. paths ,
135+ )
136+ . await ?;
137+ if files. is_empty ( ) {
138+ println ! ( "No files found in blueprint repo '{}'." , blueprint. repo) ;
139+ return Ok ( ( ) ) ;
140+ }
141+ files
142+ } else {
143+ let source_path = std:: path:: Path :: new ( & command. source ) ;
144+ if !source_path. exists ( ) {
145+ anyhow:: bail!(
146+ "sync source directory '{}' does not exist" ,
147+ command. source
148+ ) ;
149+ }
113150
114- let files = collect_sync_files ( source_path) ?;
115- if files. is_empty ( ) {
116- println ! ( "No files found in '{}'." , command. source) ;
117- return Ok ( ( ) ) ;
118- }
151+ let files = collect_sync_files ( source_path) ?;
152+ if files. is_empty ( ) {
153+ println ! ( "No files found in '{}'." , command. source) ;
154+ return Ok ( ( ) ) ;
155+ }
156+ files
157+ } ;
119158
120159 let repos = provider. list_repositories ( & config. organization ) . await ?;
121160 let repos = scope_to_config ( repos, & config. scoped_repos ) ;
@@ -148,10 +187,21 @@ impl RepoCommand {
148187 repos_to_sync. len( )
149188 ) ;
150189
151- let commit_msg = format ! (
152- "chore: sync files from devopster (source: {})" ,
153- command. source
154- ) ;
190+ let commit_msg = if use_blueprint {
191+ let repo_name = command
192+ . blueprint_repo
193+ . as_deref ( )
194+ . or_else ( || config. blueprint . as_ref ( ) . map ( |b| b. repo . as_str ( ) ) )
195+ . unwrap_or ( "blueprint" ) ;
196+ format ! (
197+ "chore: sync files from blueprint repo ({repo_name})"
198+ )
199+ } else {
200+ format ! (
201+ "chore: sync files from devopster (source: {})" ,
202+ command. source
203+ )
204+ } ;
155205 let mut sync_count = 0usize ;
156206 let mut error_count = 0usize ;
157207
@@ -259,6 +309,65 @@ fn print_audit_findings(findings: Vec<AuditFinding>) {
259309 println ! ( " then use 'devopster topics align' or 'devopster repo sync' to fix." ) ;
260310}
261311
312+ struct ResolvedBlueprintSource {
313+ repo : String ,
314+ branch : String ,
315+ paths : Vec < String > ,
316+ }
317+
318+ fn resolve_blueprint_source (
319+ config : & AppConfig ,
320+ command : & SyncReposCommand ,
321+ ) -> Result < ResolvedBlueprintSource > {
322+ let repo = command
323+ . blueprint_repo
324+ . clone ( )
325+ . or_else ( || config. blueprint . as_ref ( ) . map ( |b| b. repo . clone ( ) ) )
326+ . context ( "blueprint.repo is not configured (set it in devopster-config.yaml)" ) ?;
327+
328+ let branch = command
329+ . blueprint_branch
330+ . clone ( )
331+ . or_else ( || config. blueprint . as_ref ( ) . map ( |b| b. branch . clone ( ) ) )
332+ . unwrap_or_else ( || config. default_branch . clone ( ) ) ;
333+
334+ let mut paths = if !command. blueprint_path . is_empty ( ) {
335+ command. blueprint_path . clone ( )
336+ } else {
337+ config
338+ . blueprint
339+ . as_ref ( )
340+ . map ( |b| b. paths . clone ( ) )
341+ . unwrap_or_default ( )
342+ } ;
343+
344+ if paths. is_empty ( ) {
345+ paths. push ( ".github" . to_string ( ) ) ;
346+ }
347+
348+ Ok ( ResolvedBlueprintSource { repo, branch, paths } )
349+ }
350+
351+ fn parse_repo_slug ( input : & str ) -> Result < ( String , String ) > {
352+ let trimmed = input. trim ( ) . trim_end_matches ( '/' ) ;
353+ let slug = if let Some ( pos) = trimmed. find ( "github.com/" ) {
354+ & trimmed[ pos + "github.com/" . len ( ) ..]
355+ } else {
356+ trimmed
357+ } ;
358+
359+ let mut parts = slug. split ( '/' ) . filter ( |p| !p. is_empty ( ) ) ;
360+ let owner = parts. next ( ) . unwrap_or ( "" ) ;
361+ let repo = parts. next ( ) . unwrap_or ( "" ) ;
362+
363+ if owner. is_empty ( ) || repo. is_empty ( ) {
364+ anyhow:: bail!( "blueprint repo must be in 'org/repo' or GitHub URL format" )
365+ }
366+
367+ let repo = repo. trim_end_matches ( ".git" ) ;
368+ Ok ( ( owner. to_string ( ) , repo. to_string ( ) ) )
369+ }
370+
262371async fn fix_repos ( provider : & dyn crate :: provider:: Provider , config : & AppConfig ) -> Result < ( ) > {
263372 if config. scoped_repos . is_empty ( ) {
264373 let proceed = prompt_confirm (
0 commit comments