Skip to content

Commit aae5c84

Browse files
committed
feat(cli): Add component identity sub-command
1 parent bd6e9b5 commit aae5c84

File tree

3 files changed

+156
-0
lines changed

3 files changed

+156
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ components defined in the project configuration.`,
2626
addOnAppInit(app, cmd)
2727
buildOnAppInit(app, cmd)
2828
diffSourcesOnAppInit(app, cmd)
29+
identityOnAppInit(app, cmd)
2930
listOnAppInit(app, cmd)
3031
prepareOnAppInit(app, cmd)
3132
queryOnAppInit(app, cmd)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"fmt"
8+
"log/slog"
9+
10+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
11+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
12+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/fingerprint"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// Options for computing component identity fingerprints.
19+
type IdentityComponentOptions struct {
20+
// Standard filter for selecting components.
21+
ComponentFilter components.ComponentFilter
22+
}
23+
24+
func identityOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
25+
parentCmd.AddCommand(NewComponentIdentityCommand())
26+
}
27+
28+
// Constructs a [cobra.Command] for "component identity" CLI subcommand.
29+
func NewComponentIdentityCommand() *cobra.Command {
30+
options := &IdentityComponentOptions{}
31+
32+
cmd := &cobra.Command{
33+
Use: "identity",
34+
Short: "Compute identity fingerprints for components",
35+
Long: `Compute a deterministic identity fingerprint for each selected component.
36+
37+
The fingerprint captures all resolved build inputs (config fields, spec file
38+
content, overlay source files, distro context, and Affects commit count).
39+
A change to any input produces a different fingerprint.
40+
41+
Use this with 'component diff-identity' to determine which components need
42+
rebuilding between two commits.`,
43+
Example: ` # All components, JSON output for CI
44+
azldev component identity -a -O json > identity.json
45+
46+
# Single component, table output for dev
47+
azldev component identity -p curl
48+
49+
# Components in a group
50+
azldev component identity -g core`,
51+
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
52+
options.ComponentFilter.ComponentNamePatterns = append(
53+
args, options.ComponentFilter.ComponentNamePatterns...,
54+
)
55+
56+
return ComputeComponentIdentities(env, options)
57+
}),
58+
ValidArgsFunction: components.GenerateComponentNameCompletions,
59+
}
60+
61+
azldev.ExportAsMCPTool(cmd)
62+
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
63+
64+
return cmd
65+
}
66+
67+
// ComponentIdentityResult is the per-component output for the identity command.
68+
type ComponentIdentityResult struct {
69+
// Component is the component name.
70+
Component string `json:"component" table:",sortkey"`
71+
// Fingerprint is the overall identity hash string.
72+
Fingerprint string `json:"fingerprint"`
73+
// Inputs provides the individual input hashes (shown in JSON output).
74+
Inputs fingerprint.ComponentInputs `json:"inputs" table:"-"`
75+
}
76+
77+
// ComputeComponentIdentities computes fingerprints for all selected components.
78+
func ComputeComponentIdentities(
79+
env *azldev.Env, options *IdentityComponentOptions,
80+
) ([]ComponentIdentityResult, error) {
81+
resolver := components.NewResolver(env)
82+
83+
comps, err := resolver.FindComponents(&options.ComponentFilter)
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to resolve components:\n%w", err)
86+
}
87+
88+
distroRef := env.Config().Project.DefaultDistro
89+
90+
results := make([]ComponentIdentityResult, 0, comps.Len())
91+
92+
for _, comp := range comps.Components() {
93+
config := comp.GetConfig()
94+
componentName := comp.GetName()
95+
96+
// Count Affects commits for this component.
97+
affectsCount := countAffectsCommits(config, componentName)
98+
99+
identity, identityErr := fingerprint.ComputeIdentity(
100+
env.FS(), *config, distroRef, affectsCount,
101+
)
102+
if identityErr != nil {
103+
return nil, fmt.Errorf("computing identity for component %#q:\n%w",
104+
componentName, identityErr)
105+
}
106+
107+
results = append(results, ComponentIdentityResult{
108+
Component: componentName,
109+
Fingerprint: identity.Fingerprint,
110+
Inputs: identity.Inputs,
111+
})
112+
}
113+
114+
return results, nil
115+
}
116+
117+
// countAffectsCommits counts the number of "Affects: <componentName>" commits in the
118+
// project repo. Returns 0 if the count cannot be determined (e.g., no git repo).
119+
func countAffectsCommits(
120+
config *projectconfig.ComponentConfig, componentName string,
121+
) int {
122+
commits, err := sources.CountAffectsCommits(config, componentName)
123+
if err != nil {
124+
slog.Debug("Could not count Affects commits; defaulting to 0",
125+
"component", componentName, "error", err)
126+
127+
return 0
128+
}
129+
130+
return commits
131+
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ func FindAffectsCommits(repo *gogit.Repository, componentName string) ([]CommitM
8282
return matches, nil
8383
}
8484

85+
// CountAffectsCommits returns the number of "Affects: <componentName>" commits in the
86+
// project repository that contains the component's config file. Returns 0 and an error
87+
// if the repository cannot be opened or the config file path is unavailable.
88+
func CountAffectsCommits(
89+
config *projectconfig.ComponentConfig, componentName string,
90+
) (int, error) {
91+
configFilePath, err := resolveConfigFilePath(config, componentName)
92+
if err != nil {
93+
return 0, fmt.Errorf("resolving config file path:\n%w", err)
94+
}
95+
96+
projectRepo, _, err := openProjectRepo(configFilePath)
97+
if err != nil {
98+
return 0, fmt.Errorf("opening project repo:\n%w", err)
99+
}
100+
101+
commits, err := FindAffectsCommits(projectRepo, componentName)
102+
if err != nil {
103+
return 0, fmt.Errorf("finding Affects commits:\n%w", err)
104+
}
105+
106+
return len(commits), nil
107+
}
108+
85109
// CommitSyntheticHistory stages all pending working tree changes and creates synthetic
86110
// commits in the provided git repository. The first commit captures all file changes;
87111
// subsequent commits are created as empty commits to preserve the commit count for

0 commit comments

Comments
 (0)