Skip to content

Commit aa085ed

Browse files
author
Antonio Salinas
committed
Added --with-git flag for opt-in dist-git creation
1 parent 7bdc959 commit aa085ed

File tree

9 files changed

+181
-235
lines changed

9 files changed

+181
-235
lines changed

docs/user/reference/cli/azldev_component_build.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user/reference/cli/azldev_component_prepare-sources.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/app/azldev/cmds/component/build.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type ComponentBuildOptions struct {
2626

2727
ContinueOnError bool
2828
NoCheck bool
29+
WithGitRepo bool
2930
SourcePackageOnly bool
3031
BuildEnvPolicy BuildEnvPreservePolicy
3132

@@ -94,6 +95,8 @@ builds can consume.`,
9495
cmd.Flags().BoolVarP(&options.ContinueOnError, "continue-on-error", "k", false,
9596
"Continue building when some components fail")
9697
cmd.Flags().BoolVar(&options.NoCheck, "no-check", false, "Skip package %check tests")
98+
cmd.Flags().BoolVar(&options.WithGitRepo, "with-git", false,
99+
"Create a dist-git repository with synthetic commit history (requires a project git repository)")
97100
cmd.Flags().BoolVar(&options.SourcePackageOnly, "srpm-only", false, "Build SRPM (source RPM) *only*")
98101
cmd.Flags().Var(&options.BuildEnvPolicy, "preserve-buildenv",
99102
fmt.Sprintf("Preserve build environment {%s, %s, %s}",
@@ -212,7 +215,12 @@ func BuildComponent(
212215
return nil
213216
}, &err)
214217

215-
sourcePreparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env)
218+
var preparerOpts []sources.PreparerOption
219+
if options.WithGitRepo {
220+
preparerOpts = append(preparerOpts, sources.WithGitRepo())
221+
}
222+
223+
sourcePreparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...)
216224
if err != nil {
217225
return ComponentBuildResults{},
218226
fmt.Errorf("failed to create source preparer for component %q:\n%w", component.GetName(), err)

internal/app/azldev/cmds/component/diffsources.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ overlays to the copy and displays the resulting diff between the two trees.`,
6363
// DiffComponentSources computes the diff between original and overlaid sources for a single component.
6464
// When color is enabled and the output format is not JSON, the returned value is a pre-colorized
6565
// string. Otherwise it is [*dirdiff.DiffResult] for structured output.
66+
6667
func DiffComponentSources(env *azldev.Env, options *DiffSourcesOptions) (interface{}, error) {
6768
resolver := components.NewResolver(env)
6869

internal/app/azldev/cmds/component/preparesources.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type PrepareSourcesOptions struct {
2020

2121
OutputDir string
2222
SkipOverlays bool
23+
WithGitRepo bool
2324
Force bool
2425
}
2526

@@ -65,6 +66,8 @@ Only one component may be selected at a time.`,
6566
_ = cmd.MarkFlagDirname("output-dir")
6667

6768
cmd.Flags().BoolVar(&options.SkipOverlays, "skip-overlays", false, "skip applying overlays to prepared sources")
69+
cmd.Flags().BoolVar(&options.WithGitRepo, "with-git", false,
70+
"Create a dist-git repository with synthetic commit history (requires a project git repository)")
6871
cmd.Flags().BoolVar(&options.Force, "force", false, "delete and recreate the output directory if it already exists")
6972

7073
return cmd
@@ -114,7 +117,12 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er
114117
return err
115118
}
116119

117-
preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env)
120+
var preparerOpts []sources.PreparerOption
121+
if options.WithGitRepo {
122+
preparerOpts = append(preparerOpts, sources.WithGitRepo())
123+
}
124+
125+
preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...)
118126
if err != nil {
119127
return fmt.Errorf("failed to create source preparer:\n%w", err)
120128
}

internal/app/azldev/core/sources/sourceprep.go

Lines changed: 125 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5572
type 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.
6385
func 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.
130170
func (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.
201233
func (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

Comments
 (0)