-
Notifications
You must be signed in to change notification settings - Fork 37
prevent creating cyclical dependencies #649
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -100,6 +100,7 @@ func init() { | |
| branchCmd, | ||
| branchMetaCmd, | ||
| commitCmd, | ||
| validateDBCmd, | ||
| diffCmd, | ||
| fetchCmd, | ||
| initCmd, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "github.com/aviator-co/av/internal/git" | ||
| "github.com/aviator-co/av/internal/meta" | ||
| "github.com/aviator-co/av/internal/utils/colors" | ||
| "github.com/charmbracelet/lipgloss" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| type diagnosticSeverity string | ||
|
|
||
| const ( | ||
| diagnosticError diagnosticSeverity = "error" | ||
| diagnosticWarning diagnosticSeverity = "warning" | ||
| ) | ||
|
|
||
| type diagnosticIssue struct { | ||
| severity diagnosticSeverity | ||
| branch string | ||
| message string | ||
| } | ||
|
|
||
| var validateDBCmd = &cobra.Command{ | ||
| Use: "validate-db", | ||
| Short: "Validate av metadata", | ||
| Long: `Validate av metadata for common consistency issues, including cyclical | ||
| branch dependencies and missing parents.`, | ||
| Args: cobra.NoArgs, | ||
| RunE: func(cmd *cobra.Command, _ []string) error { | ||
| ctx := cmd.Context() | ||
| repo, err := getRepo(ctx) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| db, err := getDB(ctx, repo) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| issues, err := validateDB(ctx, repo, db.ReadTx()) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| fmt.Print(renderValidation(issues)) | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| func validateDB(ctx context.Context, repo *git.Repo, tx meta.ReadTx) ([]diagnosticIssue, error) { | ||
| branches := tx.AllBranches() | ||
| issues := make([]diagnosticIssue, 0) | ||
|
|
||
| for branchName, branch := range branches { | ||
| if branchName == "" { | ||
| continue | ||
| } | ||
|
|
||
| exists, err := repo.DoesBranchExist(ctx, branchName) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if !exists { | ||
| issues = append(issues, diagnosticIssue{ | ||
| severity: diagnosticError, | ||
| branch: branchName, | ||
| message: "branch is missing from the Git repository", | ||
| }) | ||
| continue | ||
| } | ||
|
|
||
| if branch.Parent.Name == "" && !branch.Parent.Trunk { | ||
| issues = append(issues, diagnosticIssue{ | ||
| severity: diagnosticError, | ||
| branch: branchName, | ||
| message: "parent is empty but not marked as trunk", | ||
| }) | ||
| } | ||
|
|
||
| if branch.Parent.Name == branchName { | ||
| issues = append(issues, diagnosticIssue{ | ||
| severity: diagnosticError, | ||
| branch: branchName, | ||
| message: "parent points to itself", | ||
| }) | ||
| } else if err := meta.ValidateNoCycle(tx, branchName, branch.Parent); err != nil { | ||
| issues = append(issues, diagnosticIssue{ | ||
| severity: diagnosticError, | ||
| branch: branchName, | ||
| message: err.Error(), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return issues, nil | ||
| } | ||
|
|
||
| func renderValidation(issues []diagnosticIssue) string { | ||
| if len(issues) == 0 { | ||
| return lipgloss.NewStyle().MarginTop(1).MarginBottom(1).MarginLeft(2).Render( | ||
| colors.SuccessStyle.Render("✓ No av metadata issues found"), | ||
| ) + "\n" | ||
| } | ||
|
|
||
| var errors, warnings []diagnosticIssue | ||
| for _, issue := range issues { | ||
| switch issue.severity { | ||
| case diagnosticError: | ||
| errors = append(errors, issue) | ||
| case diagnosticWarning: | ||
| warnings = append(warnings, issue) | ||
| } | ||
| } | ||
|
|
||
| var ss []string | ||
| if len(errors) > 0 { | ||
| ss = append(ss, colors.FailureStyle.Render("✗ Issues found in av metadata")) | ||
| } else { | ||
| ss = append(ss, colors.Warning("! Warnings found in av metadata")) | ||
| } | ||
|
|
||
| if len(errors) > 0 { | ||
| ss = append(ss, "") | ||
| ss = append(ss, " Errors:") | ||
| for _, issue := range errors { | ||
| ss = append(ss, fmt.Sprintf(" * %s: %s", issue.branch, issue.message)) | ||
| } | ||
| } | ||
|
|
||
| if len(warnings) > 0 { | ||
| ss = append(ss, "") | ||
| ss = append(ss, " Warnings:") | ||
| for _, issue := range warnings { | ||
| ss = append(ss, fmt.Sprintf(" * %s: %s", issue.branch, issue.message)) | ||
| } | ||
| } | ||
|
|
||
| return lipgloss.NewStyle().MarginTop(1).MarginBottom(1).MarginLeft(2).Render( | ||
| lipgloss.JoinVertical(0, ss...), | ||
| ) + "\n" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package meta | |
|
|
||
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
|
|
||
| "emperror.dev/errors" | ||
| "github.com/shurcooL/githubv4" | ||
|
|
@@ -170,6 +171,42 @@ func HasExcludedAncestor(tx ReadTx, name string) (bool, string) { | |
| return HasExcludedAncestor(tx, branch.Parent.Name) | ||
| } | ||
|
|
||
| // ValidateNoCycle returns an error if setting a branch's parent would introduce | ||
| // cyclical dependencies in the stack. | ||
| func ValidateNoCycle(tx ReadTx, branchName string, parent BranchState) error { | ||
| if parent.Trunk || parent.Name == "" { | ||
| return nil | ||
| } | ||
|
|
||
| visited := map[string]bool{branchName: true} | ||
| current := parent.Name | ||
| for current != "" { | ||
| if visited[current] { | ||
| return fmt.Errorf( | ||
| "branch %q cannot have parent %q because it would introduce cyclical branch dependencies", | ||
| branchName, | ||
| parent.Name, | ||
| ) | ||
| } | ||
| visited[current] = true | ||
|
|
||
| branch, ok := tx.Branch(current) | ||
| if !ok { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we be returning here also?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We return
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why don't we return the error here? |
||
| return fmt.Errorf( | ||
| "branch %q cannot have parent %q: ancestor branch %q is missing from av metadata", | ||
| branchName, | ||
| parent.Name, | ||
| current, | ||
| ) | ||
tulioz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| if branch.Parent.Trunk { | ||
| return nil | ||
| } | ||
| current = branch.Parent.Name | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // StackBranches returns branches in the stack associated with the given branch. | ||
| func StackBranches(tx ReadTx, name string) ([]string, error) { | ||
| root, found := Root(tx, name) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package meta | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| type testReadTx struct { | ||
| branches map[string]Branch | ||
| } | ||
|
|
||
| func (tx testReadTx) Repository() Repository { | ||
| return Repository{} | ||
| } | ||
|
|
||
| func (tx testReadTx) Branch(name string) (Branch, bool) { | ||
| branch, ok := tx.branches[name] | ||
| if ok && branch.Name == "" { | ||
| branch.Name = name | ||
| } | ||
| return branch, ok | ||
| } | ||
|
|
||
| func (tx testReadTx) AllBranches() map[string]Branch { | ||
| return tx.branches | ||
| } | ||
|
|
||
| func TestValidateNoCycle(t *testing.T) { | ||
| for _, tt := range []struct { | ||
| name string | ||
| tx ReadTx | ||
| branch string | ||
| parent BranchState | ||
| expectErr bool | ||
| }{ | ||
| { | ||
| name: "trunk parent allowed", | ||
| tx: testReadTx{branches: map[string]Branch{}}, | ||
| branch: "feature", | ||
| parent: BranchState{Name: "main", Trunk: true}, | ||
| }, | ||
| { | ||
| name: "missing parent rejected", | ||
| tx: testReadTx{branches: map[string]Branch{}}, | ||
| branch: "feature", | ||
| parent: BranchState{Name: "missing", Trunk: false}, | ||
| expectErr: true, | ||
| }, | ||
| { | ||
| name: "self parent rejected", | ||
| tx: testReadTx{branches: map[string]Branch{}}, | ||
| branch: "feature", | ||
| parent: BranchState{Name: "feature", Trunk: false}, | ||
| expectErr: true, | ||
| }, | ||
| { | ||
| name: "cycle in parent chain rejected", | ||
| tx: testReadTx{branches: map[string]Branch{ | ||
| "b": {Name: "b", Parent: BranchState{Name: "c", Trunk: false}}, | ||
| "c": {Name: "c", Parent: BranchState{Name: "a", Trunk: false}}, | ||
| "a": {Name: "a", Parent: BranchState{Name: "main", Trunk: true}}, | ||
| }}, | ||
| branch: "a", | ||
| parent: BranchState{Name: "b", Trunk: false}, | ||
| expectErr: true, | ||
| }, | ||
| } { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| err := ValidateNoCycle(tt.tx, tt.branch, tt.parent) | ||
| if tt.expectErr { | ||
| assert.Error(t, err) | ||
| if tt.name == "missing parent rejected" { | ||
| assert.Contains(t, err.Error(), "missing from av metadata") | ||
| } | ||
| if tt.name == "cycle in parent chain rejected" || tt.name == "self parent rejected" { | ||
| assert.Contains(t, err.Error(), "cyclical branch dependencies") | ||
| } | ||
| } else { | ||
| assert.NoError(t, err) | ||
| } | ||
| }) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.