-
Notifications
You must be signed in to change notification settings - Fork 20
feat: add historic git-backed filesystem #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dmcilvaney
wants to merge
4
commits into
microsoft:main
Choose a base branch
from
dmcilvaney:damcilva/gitfs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,230
−0
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
616961a
feat: add historic git-backed filesystem
dmcilvaney aeca60f
fixup! feat: add historic git-backed filesystem
dmcilvaney 6df7755
fixup! feat: add historic git-backed filesystem
dmcilvaney 23b1ef4
fixup! feat: add historic git-backed filesystem
dmcilvaney File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package projectconfig | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "path" | ||
|
|
||
| gogit "github.com/go-git/go-git/v5" | ||
| "github.com/go-git/go-git/v5/plumbing" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/utils/gitfs" | ||
| "github.com/spf13/afero" | ||
| ) | ||
|
|
||
| // historicDryRunnable reports that we are not in dry-run mode: the historic | ||
| // loader genuinely writes the embedded default configs into its in-memory | ||
| // scratch overlay. | ||
| type historicDryRunnable struct{} | ||
|
|
||
| func (historicDryRunnable) DryRun() bool { return false } | ||
|
|
||
| // historicOSEnv is a deliberately inert OS environment. Historic config loading | ||
| // must depend only on what is in the git tree, never on the host's working | ||
| // directory or user-level XDG config. Returning empty values causes the | ||
| // user-config lookup to resolve to nothing. | ||
| type historicOSEnv struct{} | ||
|
|
||
| func (historicOSEnv) Getwd() (string, error) { return "", nil } | ||
| func (historicOSEnv) Chdir(string) error { return nil } | ||
| func (historicOSEnv) Getenv(string) string { return "" } | ||
| func (historicOSEnv) IsCurrentUserMemberOf(string) (bool, error) { | ||
| return false, nil | ||
| } | ||
| func (historicOSEnv) LookupGroupID(string) (int, error) { return 0, nil } | ||
|
|
||
| // LoadProjectConfigAtCommit loads the project configuration exactly as it | ||
| // existed at a specific commit in the project repository, without checking | ||
| // anything out to disk. | ||
| // | ||
| // It reads files through a read-only [gitfs.Fs] backed by the commit's tree, | ||
| // layered under an in-memory writable overlay so the loader can stage its | ||
| // embedded default configs. The resolved configuration therefore combines the | ||
| // commit's in-tree config with azldev's built-in embedded defaults; the latter | ||
| // are part of every load and are not drawn from the git tree. Host working | ||
| // directory and user-level config are intentionally excluded, so the only | ||
| // per-invocation input is the embedded defaults baked into the binary. | ||
| // | ||
| // referenceDir is interpreted relative to the tree root (e.g. the project | ||
| // subdirectory containing azldev.toml). Both absolute ("/sub") and relative | ||
| // ("sub") forms are accepted. | ||
| func LoadProjectConfigAtCommit( | ||
| repo *gogit.Repository, | ||
| commitHash plumbing.Hash, | ||
| referenceDir string, | ||
| permissiveConfigParsing bool, | ||
| ) (projectDir string, config *ProjectConfig, err error) { | ||
| base, err := gitfs.NewFromCommit(repo, commitHash) | ||
| if err != nil { | ||
| return "", nil, fmt.Errorf("failed to open git filesystem at commit %s:\n%w", commitHash, err) | ||
| } | ||
|
|
||
| // Layer a writable in-memory overlay so the loader can stage its embedded | ||
| // default configs (and any other scratch writes) without touching the | ||
| // read-only git tree underneath. | ||
| fs := afero.NewCopyOnWriteFs(base, afero.NewMemMapFs()) | ||
|
|
||
| // Interpret referenceDir relative to the git tree root, never the host | ||
| // process working directory. path.Join against "/" makes relative forms | ||
| // ("sub", "./sub") and absolute forms ("/sub") resolve identically; an | ||
| // empty referenceDir collapses to the tree root "/". | ||
| referenceDir = path.Join("/", referenceDir) | ||
|
|
||
| return LoadProjectConfig( | ||
| historicDryRunnable{}, | ||
| fs, | ||
| historicOSEnv{}, | ||
| referenceDir, | ||
|
dmcilvaney marked this conversation as resolved.
|
||
| false, // disableDefaultConfig: defaults are part of resolved overlays. | ||
| "", // tempDirPath: empty lets the loader pick a default temp dir. | ||
| nil, // extraConfigFilePaths: none for historic loads. | ||
| permissiveConfigParsing, | ||
| ) | ||
| } | ||
|
|
||
| // ResolveComponentOverlaysAtCommit loads the project config as of the given | ||
| // commit and returns the resolved overlays for the named component, combining | ||
| // project-level defaults, component-group defaults, and the component's own | ||
| // overlays. | ||
| // | ||
| // Distro-level default overlays are intentionally excluded: resolving them | ||
| // requires distro/version selection (which depends on the live invocation, not | ||
| // the historic tree), and distro defaults are not used for version-setting | ||
| // overlays. This keeps historic resolution self-contained and deterministic. | ||
| // | ||
| // Each call performs a full LoadProjectConfigAtCommit (fresh overlay, re-staged | ||
| // defaults, re-parsed config) to extract a single component, so resolving many | ||
| // components at one commit reloads the project repeatedly. This favors a simple, | ||
| // self-contained API over performance; the currently expected workflows resolve | ||
| // few components per commit. If a caller needs many-per-commit resolution, load | ||
| // the config once and resolve against the returned *ProjectConfig instead. | ||
| // | ||
| // Returns (nil, nil) when the component is absent at that commit. | ||
| func ResolveComponentOverlaysAtCommit( | ||
| repo *gogit.Repository, | ||
| commitHash plumbing.Hash, | ||
| referenceDir string, | ||
| componentName string, | ||
| permissiveConfigParsing bool, | ||
| ) ([]ComponentOverlay, error) { | ||
| _, config, err := LoadProjectConfigAtCommit(repo, commitHash, referenceDir, permissiveConfigParsing) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| explicit, ok := config.Components[componentName] | ||
| if !ok { | ||
| return nil, nil | ||
| } | ||
|
|
||
| resolved, err := ResolveComponentConfig( | ||
| explicit, | ||
| config.DefaultComponentConfig, | ||
| ComponentConfig{}, // distro defaults excluded; see doc comment. | ||
| config.ComponentGroups, | ||
| config.GroupsByComponent[componentName], | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("resolving overlays for component %#q at commit %s:\n%w", | ||
| componentName, commitHash, err) | ||
| } | ||
|
|
||
| return resolved.Overlays, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package projectconfig_test | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/go-git/go-billy/v5" | ||
| "github.com/go-git/go-billy/v5/memfs" | ||
| gogit "github.com/go-git/go-git/v5" | ||
| "github.com/go-git/go-git/v5/plumbing" | ||
| "github.com/go-git/go-git/v5/plumbing/object" | ||
| "github.com/go-git/go-git/v5/storage/memory" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func writeWorktreeFile(t *testing.T, fs billy.Filesystem, content string) { | ||
| t.Helper() | ||
|
|
||
| file, err := fs.Create("azldev.toml") | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = file.Write([]byte(content)) | ||
| require.NoError(t, err) | ||
| require.NoError(t, file.Close()) | ||
| } | ||
|
|
||
| func commitWorktree(t *testing.T, repo *gogit.Repository, msg string) plumbing.Hash { | ||
| t.Helper() | ||
|
|
||
| worktree, err := repo.Worktree() | ||
| require.NoError(t, err) | ||
| require.NoError(t, worktree.AddGlob(".")) | ||
|
|
||
| hash, err := worktree.Commit(msg, &gogit.CommitOptions{ | ||
| Author: &object.Signature{Name: "t", Email: "t@t.com", When: time.Now()}, | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| return hash | ||
| } | ||
|
|
||
| // TestLoadProjectConfigAtCommit verifies that a component's overlays defined in | ||
| // azldev.toml are recovered when loading the project config as of a historical | ||
| // commit, reading purely from the git tree (no checkout). | ||
| func TestLoadProjectConfigAtCommit(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| writeWorktreeFile(t, bfs, ` | ||
| [components.foo] | ||
| [[components.foo.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "1\\.0\\.0" | ||
| replacement = "2.0.0" | ||
| `) | ||
|
|
||
| hash := commitWorktree(t, repo, "add foo overlay") | ||
|
|
||
| projectDir, config, err := projectconfig.LoadProjectConfigAtCommit(repo, hash, "/", false) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, config) | ||
| assert.Equal(t, "/", projectDir) | ||
|
|
||
| comp, ok := config.Components["foo"] | ||
| require.True(t, ok, "component foo should be present") | ||
| require.Len(t, comp.Overlays, 1) | ||
| assert.Equal(t, projectconfig.ComponentOverlaySearchAndReplaceInSpec, comp.Overlays[0].Type) | ||
| assert.Equal(t, "2.0.0", comp.Overlays[0].Replacement) | ||
| } | ||
|
|
||
| // TestLoadProjectConfigAtCommit_ReferenceDirIsTreeRelative verifies that a | ||
| // referenceDir naming a project subdirectory is interpreted relative to the git | ||
| // tree root, not the host process working directory. Both relative ("sub") and | ||
| // absolute ("/sub") forms must resolve to the same in-tree location. Without | ||
| // tree-relative normalization, a relative referenceDir resolves against the | ||
| // host CWD and the config file is never found in the git tree. | ||
| func TestLoadProjectConfigAtCommit_ReferenceDirIsTreeRelative(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| file, err := bfs.Create("sub/azldev.toml") | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = file.Write([]byte("[components.foo]\n")) | ||
| require.NoError(t, err) | ||
| require.NoError(t, file.Close()) | ||
|
|
||
| hash := commitWorktree(t, repo, "add config under sub/") | ||
|
|
||
| for _, referenceDir := range []string{"sub", "/sub", "./sub"} { | ||
| t.Run(referenceDir, func(t *testing.T) { | ||
| projectDir, config, err := projectconfig.LoadProjectConfigAtCommit(repo, hash, referenceDir, false) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, config) | ||
| assert.Equal(t, "/sub", projectDir) | ||
| assert.Contains(t, config.Components, "foo") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestResolveComponentOverlaysAtCommit verifies that overlays inherited from a | ||
| // component group default are merged with the component's own overlays when | ||
| // resolving historically. | ||
| func TestResolveComponentOverlaysAtCommit(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| writeWorktreeFile(t, bfs, ` | ||
| [component-groups.shared] | ||
| components = ["foo"] | ||
| [[component-groups.shared.default-component-config.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "from-group" | ||
| replacement = "group-applied" | ||
|
|
||
| [components.foo] | ||
| [[components.foo.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "from-comp" | ||
| replacement = "comp-applied" | ||
| `) | ||
|
|
||
| hash := commitWorktree(t, repo, "add group + component overlays") | ||
|
|
||
| overlays, err := projectconfig.ResolveComponentOverlaysAtCommit(repo, hash, "/", "foo", false) | ||
| require.NoError(t, err) | ||
| require.Len(t, overlays, 2) | ||
|
|
||
| replacements := []string{overlays[0].Replacement, overlays[1].Replacement} | ||
| assert.Contains(t, replacements, "group-applied") | ||
| assert.Contains(t, replacements, "comp-applied") | ||
| } | ||
|
|
||
| // TestResolveComponentOverlaysAtCommit_MissingComponent verifies that a request | ||
| // for a component absent at the commit returns nil overlays without error. | ||
| func TestResolveComponentOverlaysAtCommit_MissingComponent(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| writeWorktreeFile(t, bfs, "[components.foo]\n") | ||
|
|
||
| hash := commitWorktree(t, repo, "add foo") | ||
|
|
||
| overlays, err := projectconfig.ResolveComponentOverlaysAtCommit(repo, hash, "/", "absent", false) | ||
| require.NoError(t, err) | ||
| assert.Nil(t, overlays) | ||
| } | ||
|
|
||
| // TestResolveComponentOverlaysAtCommit_TracksHistory verifies that resolving | ||
| // overlays at an OLDER commit returns the overlay value as it existed at THAT | ||
| // commit — not the latest value. This is the core guarantee historical overlay | ||
| // replay relies on: each synthetic commit must see the version it actually | ||
| // carried at that point in history. If resolution leaked HEAD's config, every | ||
| // historic entry would show the current version. | ||
| func TestResolveComponentOverlaysAtCommit_TracksHistory(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| // Commit A: overlay replacement is 2.0.0. | ||
| writeWorktreeFile(t, bfs, ` | ||
| [components.foo] | ||
| [[components.foo.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "VERSION" | ||
| replacement = "2.0.0" | ||
| `) | ||
| hashA := commitWorktree(t, repo, "foo -> 2.0.0") | ||
|
|
||
| // Commit B: same overlay, replacement bumped to 3.0.0. | ||
| writeWorktreeFile(t, bfs, ` | ||
| [components.foo] | ||
| [[components.foo.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "VERSION" | ||
| replacement = "3.0.0" | ||
| `) | ||
| hashB := commitWorktree(t, repo, "foo -> 3.0.0") | ||
|
|
||
| overlaysA, err := projectconfig.ResolveComponentOverlaysAtCommit(repo, hashA, "/", "foo", false) | ||
| require.NoError(t, err) | ||
| require.Len(t, overlaysA, 1) | ||
| assert.Equal(t, "2.0.0", overlaysA[0].Replacement, "commit A must resolve its own (older) overlay value") | ||
|
|
||
| overlaysB, err := projectconfig.ResolveComponentOverlaysAtCommit(repo, hashB, "/", "foo", false) | ||
| require.NoError(t, err) | ||
| require.Len(t, overlaysB, 1) | ||
| assert.Equal(t, "3.0.0", overlaysB[0].Replacement, "commit B must resolve its own (newer) overlay value") | ||
| } | ||
|
|
||
| // TestResolveComponentOverlaysAtCommit_PermissiveToleratesUndefinedRef verifies | ||
| // that with permissive parsing enabled, a config whose component group references | ||
| // an undefined component still loads, so the target component's overlays can be | ||
| // recovered. Historical commits may legitimately reference components that were | ||
| // only defined in a later revision; a strict load would fail the entire resolve | ||
| // and mis-attribute the version for that commit. | ||
| func TestResolveComponentOverlaysAtCommit_PermissiveToleratesUndefinedRef(t *testing.T) { | ||
| bfs := memfs.New() | ||
|
|
||
| repo, err := gogit.Init(memory.NewStorage(), bfs) | ||
| require.NoError(t, err) | ||
|
|
||
| // "shared" group references "not-yet-defined", which has no [components] entry. | ||
| writeWorktreeFile(t, bfs, ` | ||
| [component-groups.shared] | ||
| components = ["foo", "not-yet-defined"] | ||
|
|
||
| [components.foo] | ||
| [[components.foo.overlays]] | ||
| type = "spec-search-replace" | ||
| regex = "VERSION" | ||
| replacement = "2.0.0" | ||
| `) | ||
| hash := commitWorktree(t, repo, "foo defined, dangling group ref") | ||
|
|
||
| // Strict load fails on the undefined component reference. | ||
| _, err = projectconfig.ResolveComponentOverlaysAtCommit(repo, hash, "/", "foo", false) | ||
| require.Error(t, err) | ||
|
|
||
| // Permissive load tolerates it and still returns foo's overlays. | ||
| overlays, err := projectconfig.ResolveComponentOverlaysAtCommit(repo, hash, "/", "foo", true) | ||
| require.NoError(t, err) | ||
| require.Len(t, overlays, 1) | ||
| assert.Equal(t, "2.0.0", overlays[0].Replacement) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.