@@ -4,7 +4,6 @@ package main
44
55import (
66 "encoding/json"
7- "flag"
87 "fmt"
98 "os"
109 "os/exec"
@@ -73,6 +72,7 @@ func All() error {
7372// Build builds the CLI binary and installs it locally using azd x build.
7473func Build () error {
7574 _ = killExtensionProcesses ()
75+ time .Sleep (500 * time .Millisecond )
7676
7777 // Ensure azd extensions are set up (enables extensions + installs azd x if needed)
7878 if err := ensureAzdExtensions (); err != nil {
@@ -92,7 +92,7 @@ func Build() error {
9292 }
9393
9494 // Build and install directly using azd x build
95- if err := sh . RunWithV (env , "azd" , "x" , "build" ); err != nil {
95+ if err := runWithEnvRetry (env , "azd" , "x" , "build" ); err != nil {
9696 return fmt .Errorf ("build failed: %w" , err )
9797 }
9898
@@ -183,72 +183,52 @@ const (
183183 skillsTargetPath = "src/internal/assets/ghcp4a-skills"
184184)
185185
186- // SyncSkills syncs upstream skills from microsoft/GitHub-Copilot-for-Azure
187- // using a smart merge: new upstream files are added, deleted upstream files are
188- // removed, but locally modified files are preserved.
186+ // SyncSkills syncs upstream skills from microsoft/GitHub-Copilot-for-Azure.
189187//
190- // Flags (also settable via environment variables):
188+ // When syncing from a local path, an exact sync is performed: the target is
189+ // mirrored to match the source, including file deletions and content updates.
191190//
192- // -source / SKILLS_SOURCE — path to a local clone (skips cloning)
193- // -repo / SKILLS_REPO — GitHub repo URL to clone from (default: upstream)
194- // -branch / SKILLS_BRANCH — branch to sync from (default: main)
191+ // When syncing from a remote repo, a smart merge is used: new files are added,
192+ // deleted files are removed, but locally modified files are preserved.
193+ //
194+ // The source parameter controls where to sync from:
195+ //
196+ // (empty) — clone upstream main (default)
197+ // local path — exact sync from a local clone (auto-detected)
198+ // repo URL — smart merge from a custom repo (main branch)
199+ // repo URL@branch — smart merge from a custom repo at a specific branch
195200//
196201// Examples:
197202//
198- // mage SyncSkills # upstream main (default)
199- // mage SyncSkills -source /path/to/local/clone # local folder
200- // mage SyncSkills -repo https://github.com/user/fork.git # custom repo
201- // mage SyncSkills -branch feature-x # custom branch
202- // mage SyncSkills -repo https://github.com/user/fork.git -branch x # both
203- func SyncSkills () error {
204- // Parse flags — fall back to env vars
205- fs := flag .NewFlagSet ("SyncSkills" , flag .ContinueOnError )
206- sourceFlag := fs .String ("source" , "" , "path to a local clone of the upstream repo" )
207- repoFlag := fs .String ("repo" , "" , "GitHub repo URL to clone from" )
208- branchFlag := fs .String ("branch" , "" , "branch to sync from" )
209-
210- // os.Args[0] is the binary, os.Args[1] is the target name
211- if len (os .Args ) > 2 {
212- if err := fs .Parse (os .Args [2 :]); err != nil {
213- return err
214- }
215- }
216-
217- sourceDir := * sourceFlag
218- if sourceDir == "" {
219- sourceDir = os .Getenv ("SKILLS_SOURCE" )
220- }
221- repo := * repoFlag
222- if repo == "" {
223- repo = os .Getenv ("SKILLS_REPO" )
224- }
225- branch := * branchFlag
226- if branch == "" {
227- branch = os .Getenv ("SKILLS_BRANCH" )
228- }
229-
203+ // mage SyncSkills # upstream main
204+ // mage SyncSkills C:\code\GitHub-Copilot-for-Azure # local folder
205+ // mage SyncSkills https://github.com/user/fork.git # custom repo
206+ // mage SyncSkills https://github.com/user/fork.git@my-branch # custom repo + branch
207+ func SyncSkills (source string ) error {
230208 fmt .Println ("🔄 Syncing upstream Azure skills..." )
231209
210+ var sourceDir string
232211 var tempDir string
233212
234- if sourceDir != "" {
235- // Use local clone
213+ if source == "" {
214+ // Default: clone upstream main
215+ source = skillsSourceRepo
216+ }
217+
218+ // Detect if source is a local path or a repo URL
219+ isLocal := isLocalPath (source )
220+ if isLocal {
221+ sourceDir = source
236222 skillsDir := filepath .Join (sourceDir , skillsSourcePath )
237223 if _ , err := os .Stat (skillsDir ); os .IsNotExist (err ) {
238224 return fmt .Errorf ("skills not found at %s" , skillsDir )
239225 }
240- fmt .Printf ("📂 Using local source: %s\n " , sourceDir )
226+ fmt .Printf ("📂 Using local source: %s (exact sync) \n " , sourceDir )
241227 } else {
242- // Determine repo URL and branch
243- cloneRepo := skillsSourceRepo
244- if repo != "" {
245- cloneRepo = repo
246- }
247- if branch == "" {
248- branch = "main"
249- }
228+ // Parse optional @branch suffix
229+ repo , branch := parseRepoSource (source )
250230
251- fmt .Printf ("📥 Cloning %s (branch: %s, sparse)...\n " , cloneRepo , branch )
231+ fmt .Printf ("📥 Cloning %s (branch: %s, sparse)...\n " , repo , branch )
252232 var err error
253233 tempDir , err = os .MkdirTemp ("" , "skills-sync-*" )
254234 if err != nil {
@@ -258,7 +238,7 @@ func SyncSkills() error {
258238
259239 sourceDir = tempDir
260240 cmds := [][]string {
261- {"git" , "clone" , "--depth=1" , "--branch" , branch , "--filter=blob:none" , "--sparse" , cloneRepo , tempDir },
241+ {"git" , "clone" , "--depth=1" , "--branch" , branch , "--filter=blob:none" , "--sparse" , repo , tempDir },
262242 {"git" , "-C" , tempDir , "sparse-checkout" , "set" , skillsSourcePath },
263243 }
264244 for _ , args := range cmds {
@@ -306,9 +286,7 @@ func SyncSkills() error {
306286 }
307287 if ! upstreamSet [e .Name ()] {
308288 dst := filepath .Join (skillsTargetPath , e .Name ())
309- // Check if locally modified (has uncommitted changes via git)
310- locallyModified := isLocallyModified (dst )
311- if locallyModified {
289+ if ! isLocal && isLocallyModified (dst ) {
312290 fmt .Printf (" ⚠️ %s: removed upstream but locally modified — keeping\n " , e .Name ())
313291 kept ++
314292 } else {
@@ -334,13 +312,13 @@ func SyncSkills() error {
334312 added ++
335313 } else {
336314 // Existing skill — smart merge per file
337- fileAdded , fileUpdated , fileKept , err := mergeSkillDir (src , dst )
315+ fileAdded , fileUpdated , fileKept , fileRemoved , err := mergeSkillDir (src , dst , isLocal )
338316 if err != nil {
339317 fmt .Printf (" ❌ %s: %v\n " , name , err )
340318 continue
341319 }
342- if fileAdded + fileUpdated > 0 {
343- fmt .Printf (" ✅ %s (merged: %d new, %d updated, %d kept)\n " , name , fileAdded , fileUpdated , fileKept )
320+ if fileAdded + fileUpdated + fileRemoved > 0 {
321+ fmt .Printf (" ✅ %s (merged: %d new, %d updated, %d removed, %d kept)\n " , name , fileAdded , fileUpdated , fileRemoved , fileKept )
344322 } else if fileKept > 0 {
345323 fmt .Printf (" 🔒 %s (all %d files locally modified — kept)\n " , name , fileKept )
346324 } else {
@@ -349,6 +327,7 @@ func SyncSkills() error {
349327 added += fileAdded
350328 updated += fileUpdated
351329 kept += fileKept
330+ removed += fileRemoved
352331 }
353332 }
354333
@@ -376,10 +355,66 @@ func runWithRetry(cmd string, args ...string) error {
376355 return err
377356}
378357
358+ // runWithEnvRetry runs a command with environment variables, retrying up to 3 times on failure.
359+ func runWithEnvRetry (env map [string ]string , cmd string , args ... string ) error {
360+ const maxRetries = 3
361+ var err error
362+ for i := 0 ; i < maxRetries ; i ++ {
363+ if i > 0 {
364+ delay := time .Duration (i * 5 ) * time .Second
365+ fmt .Printf (" ⚠️ Attempt %d/%d failed, retrying in %s...\n " , i , maxRetries , delay )
366+ time .Sleep (delay )
367+ }
368+ if err = sh .RunWithV (env , cmd , args ... ); err == nil {
369+ return nil
370+ }
371+ }
372+ return err
373+ }
374+
379375// mergeSkillDir merges an upstream skill directory into a local one.
380- // Files modified locally are preserved; new/unchanged upstream files are copied.
381- func mergeSkillDir (src , dst string ) (added , updated , kept int , err error ) {
382- return added , updated , kept , filepath .Walk (src , func (path string , info os.FileInfo , walkErr error ) error {
376+ // When exactSync is true (local source), all upstream changes are applied and
377+ // deleted files are removed without checking for local modifications.
378+ // When exactSync is false (remote source), locally modified files are preserved.
379+ func mergeSkillDir (src , dst string , exactSync bool ) (added , updated , kept , removed int , err error ) {
380+ // Build set of all files in upstream source (relative paths)
381+ upstreamFiles := make (map [string ]bool )
382+ err = filepath .Walk (src , func (path string , info os.FileInfo , walkErr error ) error {
383+ if walkErr != nil || info .IsDir () {
384+ return walkErr
385+ }
386+ rel , _ := filepath .Rel (src , path )
387+ upstreamFiles [rel ] = true
388+ return nil
389+ })
390+ if err != nil {
391+ return
392+ }
393+
394+ // Remove local files that no longer exist upstream
395+ err = filepath .Walk (dst , func (path string , info os.FileInfo , walkErr error ) error {
396+ if walkErr != nil || info .IsDir () {
397+ return walkErr
398+ }
399+ rel , _ := filepath .Rel (dst , path )
400+ if upstreamFiles [rel ] {
401+ return nil // File still exists upstream
402+ }
403+ if ! exactSync && isLocallyModified (path ) {
404+ kept ++
405+ return nil
406+ }
407+ fmt .Printf (" 🗑️ %s (deleted upstream)\n " , rel )
408+ os .Remove (path )
409+ removed ++
410+ return nil
411+ })
412+ if err != nil {
413+ return
414+ }
415+
416+ // Sync upstream files into local
417+ err = filepath .Walk (src , func (path string , info os.FileInfo , walkErr error ) error {
383418 if walkErr != nil {
384419 return walkErr
385420 }
@@ -408,20 +443,26 @@ func mergeSkillDir(src, dst string) (added, updated, kept int, err error) {
408443
409444 // File exists locally — compare content
410445 if string (localData ) == string (upstreamData ) {
411- // Identical — no action needed
412446 return nil
413447 }
414448
415449 // Different — check if locally modified (git tracks this)
416- if isLocallyModified (dstFile ) {
450+ if ! exactSync && isLocallyModified (dstFile ) {
417451 kept ++
418- return nil // Keep local version
452+ return nil
419453 }
420454
421455 // Not locally modified (upstream changed) — take upstream
422456 updated ++
423457 return os .WriteFile (dstFile , upstreamData , 0644 )
424458 })
459+
460+ // Clean up empty directories left after file removals
461+ if removed > 0 {
462+ removeEmptyDirs (dst )
463+ }
464+
465+ return added , updated , kept , removed , err
425466}
426467
427468// isLocallyModified checks if a path has uncommitted local modifications via git.
@@ -433,6 +474,43 @@ func isLocallyModified(path string) bool {
433474 return strings .TrimSpace (out ) != ""
434475}
435476
477+ // isLocalPath returns true if source looks like a local filesystem path
478+ // rather than a git repo URL.
479+ func isLocalPath (source string ) bool {
480+ // URLs start with a scheme or git@ notation
481+ if strings .HasPrefix (source , "https://" ) ||
482+ strings .HasPrefix (source , "http://" ) ||
483+ strings .HasPrefix (source , "git@" ) ||
484+ strings .HasPrefix (source , "ssh://" ) {
485+ return false
486+ }
487+ // Check if the path actually exists on disk
488+ _ , err := os .Stat (source )
489+ return err == nil
490+ }
491+
492+ // parseRepoSource splits a source string into repo URL and branch.
493+ // Supports "repo@branch" syntax; defaults to "main" if no branch specified.
494+ func parseRepoSource (source string ) (repo , branch string ) {
495+ // Split on last @ that comes after the scheme (to avoid splitting user@host)
496+ // Look for @ after ".git" or after the path portion
497+ repo = source
498+ branch = "main"
499+
500+ // Find @ that's not part of the scheme (git@...)
501+ // We look for @ after "github.com" or similar host portion
502+ idx := strings .LastIndex (source , "@" )
503+ if idx > 0 {
504+ // Make sure the @ isn't part of git@github.com style prefix
505+ beforeAt := source [:idx ]
506+ if strings .Contains (beforeAt , "/" ) {
507+ repo = beforeAt
508+ branch = source [idx + 1 :]
509+ }
510+ }
511+ return repo , branch
512+ }
513+
436514// copyDir recursively copies a directory tree.
437515func copyDir (src , dst string ) error {
438516 return filepath .Walk (src , func (path string , info os.FileInfo , err error ) error {
@@ -458,6 +536,25 @@ func copyDir(src, dst string) error {
458536 })
459537}
460538
539+ // removeEmptyDirs removes empty directories within root (bottom-up).
540+ func removeEmptyDirs (root string ) {
541+ // Walk bottom-up by collecting dirs first, then checking in reverse
542+ var dirs []string
543+ filepath .Walk (root , func (path string , info os.FileInfo , err error ) error {
544+ if err == nil && info .IsDir () && path != root {
545+ dirs = append (dirs , path )
546+ }
547+ return nil
548+ })
549+ // Remove in reverse order (deepest first)
550+ for i := len (dirs ) - 1 ; i >= 0 ; i -- {
551+ entries , err := os .ReadDir (dirs [i ])
552+ if err == nil && len (entries ) == 0 {
553+ os .Remove (dirs [i ])
554+ }
555+ }
556+ }
557+
461558const (
462559 customSkillsPath = "src/internal/assets/skills"
463560 agentsPath = "src/internal/assets/agents"
0 commit comments