@@ -8,12 +8,15 @@ import (
88 "errors"
99 "fmt"
1010 "log/slog"
11+ "os"
1112 "path/filepath"
1213 "slices"
1314 "strings"
15+ "time"
1416 "unicode"
1517
1618 gogit "github.com/go-git/go-git/v5"
19+ "github.com/go-git/go-git/v5/plumbing/object"
1720 "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
1821 "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
1922 "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
@@ -51,20 +54,40 @@ type SourcePreparer interface {
5154 DiffSources (ctx context.Context , component components.Component , baseDir string ) (* dirdiff.DiffResult , error )
5255}
5356
57+ // PreparerOption is a functional option for configuring a [SourcePreparer].
58+ type PreparerOption func (* sourcePreparerImpl )
59+
60+ // WithGitRepo returns a [PreparerOption] that enables dist-git repository
61+ // creation during source preparation. When set, the upstream .git directory
62+ // is preserved and synthetic commit history is generated on top of it. This
63+ // requires the project configuration to reside inside a git repository.
64+ // Without this option, no dist-git is created and synthetic history is skipped.
65+ func WithGitRepo () PreparerOption {
66+ return func (p * sourcePreparerImpl ) {
67+ p .withGitRepo = true
68+ }
69+ }
70+
5471// Standard implementation of the [SourcePreparer] interface.
5572type sourcePreparerImpl struct {
5673 sourceManager sourceproviders.SourceManager
5774 fs opctx.FS
5875 eventListener opctx.EventListener
5976 dryRunnable opctx.DryRunnable
77+
78+ // withGitRepo, when true, enables dist-git creation by preserving the
79+ // upstream .git directory and generating synthetic commit history.
80+ withGitRepo bool
6081}
6182
62- // NewPreparer creates a new [SourcePreparer] instance. All arguments are required.
83+ // NewPreparer creates a new [SourcePreparer] instance. All positional arguments
84+ // are required. Optional behavior can be configured via [PreparerOption] values.
6385func NewPreparer (
6486 sourceManager sourceproviders.SourceManager ,
6587 fs opctx.FS ,
6688 eventListener opctx.EventListener ,
6789 dryRunnable opctx.DryRunnable ,
90+ opts ... PreparerOption ,
6891) (SourcePreparer , error ) {
6992 if sourceManager == nil {
7093 return nil , errors .New ("source manager cannot be nil" )
@@ -89,6 +112,12 @@ func NewPreparer(
89112 dryRunnable : dryRunnable ,
90113 }
91114
115+ for _ , opt := range opts {
116+ if opt != nil {
117+ opt (impl )
118+ }
119+ }
120+
92121 return impl , nil
93122}
94123
@@ -103,11 +132,11 @@ func (p *sourcePreparerImpl) PrepareSources(
103132 component .GetName (), err )
104133 }
105134
106- // Preserve the upstream .git directory when overlays will be applied. This is
107- // required so that overlay commits can be appended on top of the upstream commit
108- // log during synthetic history generation.
135+ // Preserve the upstream .git directory only when dist-git creation is
136+ // requested via --with-git. This is required so that overlay commits can be
137+ // appended on top of the upstream commit log during synthetic history generation.
109138 var fetchOpts []sourceproviders.FetchComponentOption
110- if applyOverlays {
139+ if applyOverlays && p . withGitRepo {
111140 fetchOpts = append (fetchOpts , sourceproviders .WithPreserveGitDir ())
112141 }
113142
@@ -122,11 +151,22 @@ func (p *sourcePreparerImpl) PrepareSources(
122151 return nil
123152 }
124153
125- return p .applyOverlaysToSources (ctx , component , outputDir )
154+ if err := p .applyOverlaysToSources (ctx , component , outputDir ); err != nil {
155+ return err
156+ }
157+
158+ // Record the changes as synthetic git history when dist-git creation is enabled.
159+ if p .withGitRepo {
160+ if err := p .trySyntheticHistory (component , outputDir ); err != nil {
161+ return fmt .Errorf ("failed to generate synthetic history for component %#q:\n %w" ,
162+ component .GetName (), err )
163+ }
164+ }
165+
166+ return nil
126167}
127168
128- // applyOverlaysToSources writes the macros file and then applies all overlays and
129- // records synthetic git history.
169+ // applyOverlaysToSources writes the macros file and then applies all overlays.
130170func (p * sourcePreparerImpl ) applyOverlaysToSources (
131171 ctx context.Context , component components.Component , outputDir string ,
132172) error {
@@ -145,23 +185,19 @@ func (p *sourcePreparerImpl) applyOverlaysToSources(
145185 macrosFileName = filepath .Base (macrosFilePath )
146186 }
147187
148- // Apply all overlays and record synthetic git history.
149- err = p .applyOverlaysWithHistory (ctx , component , outputDir , macrosFileName )
150- if err != nil {
188+ // Apply all overlays to prepared sources.
189+ if err := p .applyOverlays (ctx , component , outputDir , macrosFileName ); err != nil {
151190 return fmt .Errorf ("failed to apply overlays for component %#q:\n %w" , component .GetName (), err )
152191 }
153192
154193 return nil
155194}
156195
157- // applyOverlaysWithHistory applies all overlays (user-defined and system-generated) to the
158- // component sources, then attempts to record the changes as synthetic git history.
159- //
160- // Overlay application is fully decoupled from git history generation: overlays are always
161- // applied first, then the changes are optionally committed. If the sources directory does
162- // not contain a git repository (e.g. local or unspecified spec sources), overlays are still
163- // applied but no synthetic history is created.
164- func (p * sourcePreparerImpl ) applyOverlaysWithHistory (
196+ // applyOverlays applies all overlays (user-defined and system-generated) to the
197+ // component sources. Overlay application is decoupled from git history generation:
198+ // overlays modify the working tree; synthetic history is recorded separately by
199+ // [trySyntheticHistory].
200+ func (p * sourcePreparerImpl ) applyOverlays (
165201 _ context.Context , component components.Component , sourcesDirPath , macrosFileName string ,
166202) error {
167203 event := p .eventListener .StartEvent ("Applying overlays" , "component" , component .GetName ())
@@ -175,7 +211,10 @@ func (p *sourcePreparerImpl) applyOverlaysWithHistory(
175211
176212 // Collect all overlays in application order. This ensures every change is
177213 // captured in the synthetic history, including build configuration changes.
178- allOverlays := p .collectOverlays (component , macrosFileName )
214+ allOverlays , err := p .collectOverlays (component , macrosFileName )
215+ if err != nil {
216+ return fmt .Errorf ("failed to collect overlays for component %#q:\n %w" , component .GetName (), err )
217+ }
179218
180219 if len (allOverlays ) == 0 {
181220 return nil
@@ -186,21 +225,14 @@ func (p *sourcePreparerImpl) applyOverlaysWithHistory(
186225 return fmt .Errorf ("failed to apply overlays for component %#q:\n %w" , component .GetName (), err )
187226 }
188227
189- // Record the changes as synthetic git history. This is required for rpmautospec
190- // release numbering and delta builds, so failure here is fatal.
191- if err := p .trySyntheticHistory (component , sourcesDirPath ); err != nil {
192- return fmt .Errorf ("failed to generate synthetic history for component %#q:\n %w" ,
193- component .GetName (), err )
194- }
195-
196228 return nil
197229}
198230
199231// collectOverlays gathers all overlays for a component into a single ordered slice:
200- // component overlays first, followed by macros-load, check-skip, and file-header overlays.
232+ // user overlays first, followed by macros-load, check-skip, and file-header overlays.
201233func (p * sourcePreparerImpl ) collectOverlays (
202234 component components.Component , macrosFileName string ,
203- ) []projectconfig.ComponentOverlay {
235+ ) ( []projectconfig.ComponentOverlay , error ) {
204236 config := component .GetConfig ()
205237
206238 var allOverlays []projectconfig.ComponentOverlay
@@ -210,10 +242,7 @@ func (p *sourcePreparerImpl) collectOverlays(
210242 if macrosFileName != "" {
211243 macroOverlays , err := synthesizeMacroLoadOverlays (macrosFileName )
212244 if err != nil {
213- slog .Error ("Failed to compute macros load overlays" ,
214- "component" , component .GetName (), "error" , err )
215-
216- panic (fmt .Sprintf ("failed to compute macros load overlays for component %q: %v" , component .GetName (), err ))
245+ return nil , fmt .Errorf ("failed to compute macros load overlays:\n %w" , err )
217246 }
218247
219248 allOverlays = append (allOverlays , macroOverlays ... )
@@ -222,38 +251,57 @@ func (p *sourcePreparerImpl) collectOverlays(
222251 allOverlays = append (allOverlays , synthesizeCheckSkipOverlays (config .Build .Check )... )
223252 allOverlays = append (allOverlays , generateFileHeaderOverlay ()... )
224253
225- return allOverlays
254+ return allOverlays , nil
226255}
227256
228- // trySyntheticHistory attempts to create synthetic git commits on top of the upstream
229- // repository. All file changes must already be present in the working tree before calling
230- // this function — it only handles staging and committing.
231- //
232- // Returns nil when there is no .git directory (legitimate for local/unspecified specs).
233- // Returns a non-nil error if a .git directory exists but history generation fails.
234- func (p * sourcePreparerImpl ) trySyntheticHistory (
235- component components.Component ,
236- sourcesDirPath string ,
237- ) error {
238- // Check for an upstream git repository in the sources directory. Local and
239- // unspecified spec sources won't have a .git directory — that's expected.
240- gitDirPath := filepath .Join (sourcesDirPath , ".git" )
257+ // initSourcesRepo initializes a new git repository in sourcesDirPath, stages all files,
258+ // and creates an initial commit. This is used for components that don't have an upstream
259+ // dist-git so that Affects commits can still be layered on top.
260+ func initSourcesRepo (sourcesDirPath string ) (* gogit.Repository , error ) {
261+ slog .Info ("Initializing git repository for sources" , "path" , sourcesDirPath )
241262
242- hasGitDir , err := fileutils . Exists ( p . fs , gitDirPath )
263+ repo , err := gogit . PlainInit ( sourcesDirPath , false )
243264 if err != nil {
244- return fmt .Errorf ("failed to check for . git directory at %#q:\n %w" , gitDirPath , err )
265+ return nil , fmt .Errorf ("failed to initialize git repository at %#q:\n %w" , sourcesDirPath , err )
245266 }
246267
247- if ! hasGitDir {
248- slog .Debug ("No .git directory in sources; skipping synthetic history" ,
249- "component" , component .GetName ())
268+ worktree , err := repo .Worktree ()
269+ if err != nil {
270+ return nil , fmt .Errorf ("failed to get worktree:\n %w" , err )
271+ }
250272
251- return nil
273+ if err := worktree .AddWithOptions (& gogit.AddOptions {All : true }); err != nil {
274+ return nil , fmt .Errorf ("failed to stage files:\n %w" , err )
275+ }
276+
277+ _ , err = worktree .Commit ("Initial sources" , & gogit.CommitOptions {
278+ Author : & object.Signature {
279+ Name : "azldev" ,
280+ Email : "azldev@microsoft.com" ,
281+ When : time .Unix (0 , 0 ).UTC (),
282+ },
283+ })
284+ if err != nil {
285+ return nil , fmt .Errorf ("failed to create initial commit:\n %w" , err )
252286 }
253287
288+ return repo , nil
289+ }
290+
291+ // trySyntheticHistory attempts to create synthetic git commits on top of the
292+ // component's sources directory. If no .git directory exists, one is initialized
293+ // with an initial commit so Affects commits can be layered on uniformly for all
294+ // component types.
295+ //
296+ // Returns nil when there are no Affects commits to apply.
297+ // Returns a non-nil error if history generation fails.
298+ func (p * sourcePreparerImpl ) trySyntheticHistory (
299+ component components.Component ,
300+ sourcesDirPath string ,
301+ ) error {
254302 config := component .GetConfig ()
255303
256- // Build commit metadata from Affects commits and dirty state .
304+ // Build commit metadata from Affects commits.
257305 commits , err := buildSyntheticCommits (config , component .GetName ())
258306 if err != nil {
259307 return fmt .Errorf ("failed to build synthetic commits:\n %w" , err )
@@ -266,10 +314,31 @@ func (p *sourcePreparerImpl) trySyntheticHistory(
266314 return nil
267315 }
268316
269- // Open the upstream git repository where synthetic commits will be recorded.
317+ // Check for an existing git repository in the sources directory.
318+ // Use os.Stat rather than p.fs because go-git's PlainInit/PlainOpen always
319+ // operate on the real OS filesystem — the check must use the same source of
320+ // truth to avoid disagreement when p.fs is an in-memory FS (e.g. unit tests).
321+ gitDirPath := filepath .Join (sourcesDirPath , ".git" )
322+
323+ _ , statErr := os .Stat (gitDirPath )
324+
325+ if statErr != nil && ! os .IsNotExist (statErr ) {
326+ return fmt .Errorf ("failed to check for .git directory at %#q:\n %w" , gitDirPath , statErr )
327+ }
328+
329+ if os .IsNotExist (statErr ) {
330+ slog .Info ("No .git directory in sources; initializing repository" ,
331+ "component" , component .GetName ())
332+
333+ if _ , err := initSourcesRepo (sourcesDirPath ); err != nil {
334+ return fmt .Errorf ("failed to initialize sources repository:\n %w" , err )
335+ }
336+ }
337+
338+ // Open the git repository where synthetic commits will be recorded.
270339 sourcesRepo , err := gogit .PlainOpen (sourcesDirPath )
271340 if err != nil {
272- return fmt .Errorf ("failed to open upstream repository at %#q:\n %w" , sourcesDirPath , err )
341+ return fmt .Errorf ("failed to open sources repository at %#q:\n %w" , sourcesDirPath , err )
273342 }
274343
275344 if err := CommitSyntheticHistory (sourcesRepo , commits ); err != nil {
0 commit comments