From 3c0d6588b9de75caa8ccc208472a76739c722d54 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 15 Nov 2023 02:22:05 +0100 Subject: [PATCH] feat: git change detection works on any commit --- cmd/terramate/cli/cli.go | 12 +- cmd/terramate/cli/project.go | 90 ++-- cmd/terramate/e2etests/core/general_test.go | 435 +++++++++++++++++++- cmd/terramate/e2etests/core/run_test.go | 4 +- git/git.go | 121 ++++++ git/git_test.go | 307 ++++++++++++++ test/sandbox/git.go | 8 + 7 files changed, 915 insertions(+), 62 deletions(-) diff --git a/cmd/terramate/cli/cli.go b/cmd/terramate/cli/cli.go index 42aa867d1..0b2d20ff4 100644 --- a/cmd/terramate/cli/cli.go +++ b/cmd/terramate/cli/cli.go @@ -766,22 +766,16 @@ func (c *cli) setupGit() { return } - remoteCheckFailed := false - if err := c.prj.checkDefaultRemote(); err != nil { if c.prj.git.remoteConfigured { fatalWithDetails(err, "checking git default remote") - } else { - remoteCheckFailed = true } } if c.parsedArgs.GitChangeBase != "" { - c.prj.baseRef = c.parsedArgs.GitChangeBase - } else if remoteCheckFailed { - c.prj.baseRef = c.prj.defaultLocalBaseRef() + c.prj.baseRev = c.parsedArgs.GitChangeBase } else { - c.prj.baseRef = c.prj.defaultBaseRef() + c.prj.baseRev = c.prj.selectChangeBase() } } @@ -2248,7 +2242,7 @@ func (c *cli) gitSafeguardRemoteEnabled() bool { func (c *cli) wd() string { return c.prj.wd } func (c *cli) rootdir() string { return c.prj.rootdir } func (c *cli) cfg() *config.Root { return &c.prj.root } -func (c *cli) baseRef() string { return c.prj.baseRef } +func (c *cli) baseRef() string { return c.prj.baseRev } func (c *cli) stackManager() *stack.Manager { return c.prj.stackManager } func (c *cli) rootNode() hcl.Config { return c.prj.root.Tree().Node } func (c *cli) cred() credential { return c.cloud.client.Credential.(credential) } diff --git a/cmd/terramate/cli/project.go b/cmd/terramate/cli/project.go index 95c702dc3..37bb674c3 100644 --- a/cmd/terramate/cli/project.go +++ b/cmd/terramate/cli/project.go @@ -20,7 +20,7 @@ type project struct { wd string isRepo bool root config.Root - baseRef string + baseRev string normalizedRepo string stackManager *stack.Manager @@ -63,21 +63,6 @@ func (p *project) prettyRepo() string { return p.normalizedRepo } -func (p *project) localDefaultBranchCommit() string { - if p.git.localDefaultBranchCommit != "" { - return p.git.localDefaultBranchCommit - } - gitcfg := p.gitcfg() - refName := gitcfg.DefaultRemote + "/" + gitcfg.DefaultBranch - val, err := p.git.wrapper.RevParse(refName) - if err != nil { - fatalWithDetails(err, "unable to git rev-parse") - } - - p.git.localDefaultBranchCommit = val - return val -} - func (p *project) isGitFeaturesEnabled() bool { return p.isRepo && p.hasCommit() } @@ -126,47 +111,58 @@ func (p *project) remoteDefaultCommit() string { return p.git.remoteDefaultBranchCommit } -func (p *project) isDefaultBranch() bool { - git := p.gitcfg() - branch, err := p.git.wrapper.CurrentBranch() - if err != nil { - // WHY? - // The current branch name (the symbolic-ref of the HEAD) is not always - // available, in this case we naively check if HEAD == local origin/main. - // This case usually happens in the git setup of CIs. - return p.localDefaultBranchCommit() == p.headCommit() - } +// selectChangeBase returns the revision used for change comparison based on the current Git state. +func (p *project) selectChangeBase() string { + gitcfg := p.gitcfg() + gw := p.git.wrapper - return branch == git.DefaultBranch -} + // Try using remote default branch first + defaultBranchRev, _ := gw.RevParse(gitcfg.DefaultRemote + "/" + gitcfg.DefaultBranch) + if defaultBranchRev == "" { + // Fall back to local default branch + defaultBranchRev, _ = gw.RevParse(gitcfg.DefaultBranch) -// defaultBaseRef returns the baseRef for the current git environment. -func (p *project) defaultBaseRef() string { - if p.isDefaultBranch() && - p.remoteDefaultCommit() == p.headCommit() { - _, err := p.git.wrapper.RevParse(defaultBranchBaseRef) - if err == nil { + if defaultBranchRev == "" { + // There's no default branch available, so we can't look for a common parent with it. return defaultBranchBaseRef } } - return p.defaultBranchRef() -} -// defaultLocalBaseRef returns the baseRef in case there's no remote setup. -func (p *project) defaultLocalBaseRef() string { - git := p.gitcfg() - if p.isDefaultBranch() { - _, err := p.git.wrapper.RevParse(defaultBranchBaseRef) - if err == nil { + branch, _ := gw.CurrentBranch() + + // Either we are on a branch or at a detached HEAD. + if branch != "" { + if branch == gitcfg.DefaultBranch { + // We are at the tip of the default branch -> latest default commit. + return defaultBranchBaseRef + } + + // Fallthrough to common parent if not on default branch + } else { + headRev, _ := gw.RevParse("HEAD") + isDetachedDefaultBranchTip := headRev == defaultBranchRev + if isDetachedDefaultBranchTip { + // We are at the latest commit of the default branch. return defaultBranchBaseRef } + + isDefaultBranchAncestor, _ := gw.IsFirstParentAncestor("HEAD", defaultBranchRev) + if isDefaultBranchAncestor { + // We are at an older commit of the default branch. + return defaultBranchBaseRef + } + + // Fallthrough to common parent if not at commit of default branch + } + + commonParentWithDefaultBranch, _ := gw.FindNearestCommonParent(defaultBranchRev, "HEAD") + if commonParentWithDefaultBranch != "" { + // We have a nearest common parent with the default branch. Similar to the historic merge base. + return commonParentWithDefaultBranch } - return git.DefaultBranch -} -func (p project) defaultBranchRef() string { - git := p.gitcfg() - return git.DefaultRemote + "/" + git.DefaultBranch + // Fall back to default. Should never happen unless running on an isolated commit. + return defaultBranchBaseRef } func (p *project) setDefaults() error { diff --git a/cmd/terramate/e2etests/core/general_test.go b/cmd/terramate/e2etests/core/general_test.go index bd60f7920..2118d4ea3 100644 --- a/cmd/terramate/e2etests/core/general_test.go +++ b/cmd/terramate/e2etests/core/general_test.go @@ -6,8 +6,10 @@ package core_test import ( "fmt" "path/filepath" + "strings" "testing" + "github.com/madlambda/spells/assert" "github.com/terramate-io/terramate/cmd/terramate/cli" . "github.com/terramate-io/terramate/cmd/terramate/e2etests/internal/runner" "github.com/terramate-io/terramate/test" @@ -255,6 +257,432 @@ func TestDefaultBaseRefInMain(t *testing.T) { AssertRunResult(t, cli.ListChangedStacks(), want) } +func TestChangeBaseSelection1(t *testing.T) { + t.Parallel() + + s := sandbox.New(t) + cli := NewCLI(t, s.RootDir()) + git := s.Git() + + hashToName := map[string]string{"": ""} + nameToHash := map[string]string{"": ""} + + setNamedCommit := func(name string) { + hash := git.RevParse("HEAD") + + hashToName[hash] = name + nameToHash[name] = hash + } + + makeStackCommit := func(name string) { + st := s.CreateStack(name) + st.CreateFile("main.tf", "# none") + git.Add(name) + git.Commit(name) + + setNamedCommit(name) + } + + type testcase struct { + Commit string + Ref string + + WantChanged []string + } + + var tests []testcase + + makeStackCommit("main_c1") + + tests = append(tests, []testcase{ + { + Commit: "main_c1", + WantChanged: []string{ + "main_c1", + }, + }}..., + ) + + git.CheckoutNew("merged1") + makeStackCommit("merged1_c1") + makeStackCommit("merged1_c2") + + tests = append(tests, []testcase{ + { + Commit: "merged1_c1", + WantChanged: []string{ + "merged1_c1", + }, + }, + { + Commit: "merged1_c2", + WantChanged: []string{ + "merged1_c1", + "merged1_c2", + }, + }, + { + Ref: "merged1", + WantChanged: []string{ + "merged1_c1", + "merged1_c2", + }, + }}..., + ) + + git.Checkout("main") + git.Merge("merged1") + setNamedCommit("main_c2") + + tests = append(tests, []testcase{ + { + Commit: "main_c2", + WantChanged: []string{ + "merged1_c1", + "merged1_c2", + }, + }}..., + ) + + git.CheckoutNew("unmerged") + makeStackCommit("unmerged_c1") + + tests = append(tests, []testcase{ + { + Commit: "unmerged_c1", + WantChanged: []string{ + "unmerged_c1", + }, + }, + { + Ref: "unmerged", + WantChanged: []string{ + "unmerged_c1", + }, + }}..., + ) + + git.Checkout("main") + + git.CheckoutNew("merged2") + makeStackCommit("merged2_c1") + + tests = append(tests, []testcase{ + { + Commit: "merged2_c1", + WantChanged: []string{ + "merged2_c1", + }, + }, + { + Ref: "merged2", + WantChanged: []string{ + "merged2_c1", + }, + }}..., + ) + + git.Checkout("main") + git.Merge("merged2") + setNamedCommit("main_c3") + + git.Push("main") // origin/main -> main_c3 + + tests = append(tests, []testcase{ + { + Commit: "main_c3", + WantChanged: []string{ + "merged2_c1", + }, + }, + { + Ref: "origin/main", + WantChanged: []string{ + "merged2_c1", + }, + }}..., + ) + + git.CheckoutNew("empty") + + tests = append(tests, []testcase{ + { + Ref: "empty", + WantChanged: []string{}, + }}..., + ) + + git.CheckoutNew("wip") + makeStackCommit("wip_c1") + makeStackCommit("wip_c2") + + tests = append(tests, []testcase{ + { + Commit: "wip_c1", + WantChanged: []string{ + "wip_c1", + }, + }, + { + Commit: "wip_c2", + WantChanged: []string{ + "wip_c1", + "wip_c2", + }, + }, + { + Ref: "wip", + WantChanged: []string{ + "wip_c1", + "wip_c2", + }, + }}..., + ) + + git.Checkout("main") + makeStackCommit("main_c4") + makeStackCommit("main_c5") + + tests = append(tests, []testcase{ + { + Commit: "main_c4", + WantChanged: []string{ + "main_c4", + }, + }, + { + Commit: "main_c5", + WantChanged: []string{ + "main_c4", + "main_c5", + }, + }, + { + Ref: "main", + WantChanged: []string{ + "main_c5", + }, + }}..., + ) + + for _, tc := range tests { + var name string + if tc.Commit != "" { + name = tc.Commit + } else { + name = tc.Ref + } + + t.Run(name, func(t *testing.T) { + assert.IsTrue(t, (tc.Commit != "") != (tc.Ref != ""), "set either commit or ref") + var rev string + if tc.Commit != "" { + rev = nameToHash[tc.Commit] + } else { + rev = tc.Ref + } + + git.Checkout(rev) + + wantStdout := "" + if len(tc.WantChanged) != 0 { + wantStdout = strings.Join(tc.WantChanged, "\n") + "\n" + } + + want := RunExpected{Stdout: wantStdout} + AssertRunResult(t, cli.ListChangedStacks(), want) + }) + } +} + +func TestChangeBaseSelection2(t *testing.T) { + t.Parallel() + + s := sandbox.New(t) + cli := NewCLI(t, s.RootDir()) + git := s.Git() + + hashToName := map[string]string{"": ""} + nameToHash := map[string]string{"": ""} + + setNamedCommit := func(name string) { + hash := git.RevParse("HEAD") + + hashToName[hash] = name + nameToHash[name] = hash + } + + makeStackCommit := func(name string) { + st := s.CreateStack(name) + st.CreateFile("main.tf", "# none") + git.Add(name) + git.Commit(name) + + setNamedCommit(name) + } + + type testcase struct { + Commit string + Ref string + + WantChanged []string + } + + var tests []testcase + + makeStackCommit("A") + + tests = append(tests, []testcase{ + { + Commit: "A", + WantChanged: []string{ + "A", + }, + }}..., + ) + + git.CheckoutNew("merged") + makeStackCommit("A0") + git.CheckoutNew("unmerged") + makeStackCommit("A2") + git.Checkout("merged") + makeStackCommit("A1") + + tests = append(tests, []testcase{ + { + Commit: "A0", + WantChanged: []string{ + "A0", + }, + }, + { + Commit: "A1", + WantChanged: []string{ + "A0", + "A1", + }, + }, + { + Ref: "merged", + WantChanged: []string{ + "A0", + "A1", + }, + }, + { + Commit: "A2", + WantChanged: []string{ + "A0", + "A2", + }, + }, + { + Ref: "unmerged", + WantChanged: []string{ + "A0", + "A2", + }, + }}..., + ) + + git.Checkout("main") + git.Merge("merged") + setNamedCommit("B") + + git.CheckoutNew("empty") + + tests = append(tests, []testcase{ + { + Ref: "empty", + WantChanged: []string{}, + }}..., + ) + + git.Checkout("main") + git.CheckoutNew("wip") + makeStackCommit("B0") + + git.Checkout("main") + makeStackCommit("C") + + git.Checkout("wip") + git.Merge("main") + setNamedCommit("C0") + + git.Checkout("main") + makeStackCommit("D") + + tests = append(tests, []testcase{ + { + Commit: "B0", + WantChanged: []string{ + "B0", + }, + }, + { + Commit: "C", + WantChanged: []string{ + "C", + }, + }, + { + Commit: "C0", + WantChanged: []string{ + "B0", + }, + }, + { + Ref: "wip", + WantChanged: []string{ + "B0", + }, + }, + { + Commit: "D", + WantChanged: []string{ + "D", + }, + }, + { + Ref: "main", + WantChanged: []string{ + "D", + }, + }}..., + ) + + git.Push("main") + + for _, tc := range tests { + var name string + if tc.Commit != "" { + name = tc.Commit + } else { + name = tc.Ref + } + + t.Run(name, func(t *testing.T) { + assert.IsTrue(t, (tc.Commit != "") != (tc.Ref != ""), "set either commit or ref") + var rev string + if tc.Commit != "" { + rev = nameToHash[tc.Commit] + } else { + rev = tc.Ref + } + + git.Checkout(rev) + + wantStdout := "" + if len(tc.WantChanged) != 0 { + wantStdout = strings.Join(tc.WantChanged, "\n") + "\n" + } + + want := RunExpected{Stdout: wantStdout} + AssertRunResult(t, cli.ListChangedStacks(), want) + }) + } +} + func TestBaseRefFlagPrecedenceOverDefault(t *testing.T) { t.Parallel() @@ -290,19 +718,16 @@ func TestMainAfterOriginMainMustUseDefaultBaseRef(t *testing.T) { git.Commit(name) } - wantStdout := "" - // creates N commits in main. - // in this case, it should use origin/main as baseRef even if in main. + // in this case, it should use HEAD^ as baseRef. for i := 0; i < 10; i++ { name := fmt.Sprintf("stack-%d", i) createCommittedStack(name) - wantStdout += name + "\n" } wantRes := RunExpected{ - Stdout: wantStdout, + Stdout: "stack-9\n", } AssertRunResult(t, ts.ListChangedStacks(), wantRes) diff --git a/cmd/terramate/e2etests/core/run_test.go b/cmd/terramate/e2etests/core/run_test.go index 583c58fc8..d3cea4fd5 100644 --- a/cmd/terramate/e2etests/core/run_test.go +++ b/cmd/terramate/e2etests/core/run_test.go @@ -2904,8 +2904,10 @@ func TestRunFailIfGitSafeguardUncommitted(t *testing.T) { mainTfFileName), RunExpected{ Stdout: mainTfAlteredContents, }) + + // Provide change base so we don't compare to the config commit. AssertRunResult(t, cli.Run( - "--changed", "run", "--quiet", HelperPath, + "--changed", "--git-change-base", "HEAD^^", "run", "--quiet", HelperPath, "cat", mainTfFileName), RunExpected{ Stdout: mainTfAlteredContents, }) diff --git a/git/git.go b/git/git.go index 7cabe7f98..6e3fc0f58 100644 --- a/git/git.go +++ b/git/git.go @@ -737,6 +737,127 @@ func (git *Git) GetConfigValue(key string) (string, error) { return strings.TrimSpace(s), nil } +// IsFirstParentAncestor returns if a is on a first parent ancestor path of b. +func (git *Git) IsFirstParentAncestor(a, b string) (bool, error) { + apLenStr, err := git.exec("rev-list", "--count", "--first-parent", "--ancestry-path", + fmt.Sprintf("%s..%s", a, b)) + if err != nil { + return false, err + } + + apLen, err := strconv.ParseInt(apLenStr, 10, 64) + if err != nil { + return false, err + } + + // The above flags will also return a path for direct non-first parents. + // Maybe this could be considered a bug in rev-list. Example: + // + // * 2d88e3e (HEAD -> main) Merge branch 'branch' + // |\ + // | * 1222a5d (branch) branch change 2 + // | * 9ca168f branch change 1 + // * | dae608c main change 1 + // |/ + // * 67834ff initial + // + // `rev-list --first-parent --ancestry-path 1222a5d..main` => 2d88e3e + // + // That's why we get the length of the path (in this example case N=1) + // and compare against main~N, which is the actual Nth first parent + // (in this case 1222a5d != dae608c). + + nthFirstParent, err := git.RevParse(fmt.Sprintf("%s~%d", b, apLen)) + if err != nil { + return false, err + } + + aRev, err := git.RevParse(a) + if err != nil { + return false, err + } + + return aRev == nthFirstParent, nil +} + +// RevList executes the git rev-list command, which typically returns a list of parents. +// Note: rev-list can be called with many parameters. +// We assume it's used so that a list of strings is returned. +func (git *Git) RevList(args ...string) ([]string, error) { + ret, err := git.exec("rev-list", args...) + if err != nil { + return nil, err + } + + revs := strings.Split(ret, "\n") + + var cleanrevs []string + for _, r := range revs { + r = strings.TrimSpace(r) + if r != "" { + cleanrevs = append(cleanrevs, r) + } + } + return cleanrevs, nil +} + +// IsAncestor returns if a is an ancestor of b. +func (git *Git) IsAncestor(a, b string) (bool, error) { + _, err := git.exec("merge-base", "--is-ancestor", a, b) + return err == nil, nil +} + +// FindNearestCommonParent returns the nearest common ancestor on the first parent path of a and b. +func (git *Git) FindNearestCommonParent(a, b string) (string, error) { + as, err := git.RevList("--first-parent", a) + if err != nil { + return "", err + } + + bs, err := git.RevList("--first-parent", b) + if err != nil { + return "", err + } + + alen := len(as) + blen := len(bs) + + if alen == 0 || blen == 0 { + return "", nil + } + + nearestCommonParent := "" + negIdx := 1 + + // First find the fork point + for negIdx <= alen && negIdx <= blen { + acur := as[alen-negIdx] + bcur := bs[blen-negIdx] + + if acur != bcur { + break + } + + nearestCommonParent = acur + negIdx++ + } + + // Then check if there's a nearer parent on a's first parent path. + for negIdx <= alen { + acur := as[alen-negIdx] + + isAncestor, _ := git.IsAncestor(acur, b) + if !isAncestor { + break + } + + nearestCommonParent = acur + negIdx++ + } + + return nearestCommonParent, nil +} + func (git *Git) exec(command string, args ...string) (string, error) { cfg := git.cfg() cmd := exec.Cmd{ diff --git a/git/git_test.go b/git/git_test.go index b80226e1a..ae8859b69 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -462,6 +462,313 @@ func TestGetConfigValue(t *testing.T) { assert.Error(t, err, "git config: non-existing key") } +func TestFindNearestCommonParent(t *testing.T) { + repodir := test.EmptyRepo(t, false) + + gw, err := git.WithConfig(git.Config{ + WorkingDir: repodir, + Isolated: true, + AllowPorcelain: true, + }) + assert.NoError(t, err, "new git wrapper") + + hashToName := map[string]string{"": ""} + nameToHash := map[string]string{"": ""} + + setNamedCommit := func(name string) { + hash, err := gw.RevParse("HEAD") + assert.NoError(t, err, "git rev-parse") + + hashToName[hash] = name + nameToHash[name] = hash + } + + makeNamedCommit := func(name string) { + test.WriteFile(t, repodir, name, "") + assert.NoError(t, gw.Add(name), "git add") + assert.NoError(t, gw.Commit(name), "git commit") + + setNamedCommit(name) + } + + type testcase struct { + Commit string + Ref string + ForkedFrom string + + WantForkPoint string + WantIsMainCommit bool + } + + var tests []testcase + + makeNamedCommit("main_commit_1") + + tests = append(tests, []testcase{ + { + Commit: "main_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_1", + WantIsMainCommit: true, + }}..., + ) + + assert.NoError(t, gw.Checkout("branch_a", true)) + makeNamedCommit("branch_a_commit_1") + makeNamedCommit("branch_a_commit_2") + + tests = append(tests, []testcase{ + { + Commit: "branch_a_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_1", + }, + { + Commit: "branch_a_commit_2", + ForkedFrom: "main", + WantForkPoint: "main_commit_1", + }, + { + Ref: "branch_a", + ForkedFrom: "main", + WantForkPoint: "main_commit_1", + }}..., + ) + + assert.NoError(t, gw.Checkout("main", false)) + assert.NoError(t, gw.Merge("branch_a")) + setNamedCommit("main_commit_2") + + tests = append(tests, []testcase{ + { + Commit: "main_commit_2", + ForkedFrom: "main", + WantForkPoint: "main_commit_2", + WantIsMainCommit: true, + }}..., + ) + + assert.NoError(t, gw.Checkout("branch_b", true)) + makeNamedCommit("branch_b_commit_1") + makeNamedCommit("branch_b_commit_2") + + tests = append(tests, []testcase{ + { + ForkedFrom: "main", + Commit: "branch_b_commit_1", + WantForkPoint: "main_commit_2", + }, + { + ForkedFrom: "main", + Commit: "branch_b_commit_2", + WantForkPoint: "main_commit_2", + }, + { + Ref: "branch_b", + ForkedFrom: "main", + WantForkPoint: "main_commit_2", + }}..., + ) + + assert.NoError(t, gw.Checkout("main", false)) + assert.NoError(t, gw.Merge("branch_b")) + setNamedCommit("main_commit_3") + + tests = append(tests, []testcase{ + { + Commit: "main_commit_3", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + WantIsMainCommit: true, + }}..., + ) + + assert.NoError(t, gw.Checkout("branch_unmerged", true)) + makeNamedCommit("branch_unmerged_commit_1") + makeNamedCommit("branch_unmerged_commit_2") + makeNamedCommit("branch_unmerged_commit_3") + + tests = append(tests, []testcase{ + { + Commit: "branch_unmerged_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "branch_unmerged_commit_2", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "branch_unmerged_commit_3", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Ref: "branch_unmerged", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }}..., + ) + + assert.NoError(t, gw.Checkout("main", false)) + assert.NoError(t, gw.Checkout("branch_c", true)) + makeNamedCommit("branch_c_commit_1") + + assert.NoError(t, gw.Checkout("branch_d", true)) + makeNamedCommit("branch_d_commit_1") + + assert.NoError(t, gw.Checkout("branch_c", false)) + assert.NoError(t, gw.Merge("branch_d")) + setNamedCommit("branch_c_commit_2") + + makeNamedCommit("branch_c_commit_3") + + assert.NoError(t, gw.Checkout("main", false)) + assert.NoError(t, gw.Merge("branch_c")) + setNamedCommit("main_commit_4") + + tests = append(tests, []testcase{ + { + Commit: "branch_c_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "branch_d_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "branch_d_commit_1", + ForkedFrom: "branch_c", + WantForkPoint: "branch_c_commit_1", + }, + { + Commit: "branch_c_commit_2", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "branch_c_commit_3", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Commit: "main_commit_4", + ForkedFrom: "main", + WantForkPoint: "main_commit_4", + WantIsMainCommit: true, + }, + { + Ref: "branch_d", + ForkedFrom: "branch_c", + WantForkPoint: "branch_c_commit_1", + }, + { + Ref: "branch_d", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Ref: "branch_c", + ForkedFrom: "main", + WantForkPoint: "main_commit_3", + }, + { + Ref: "branch_c", + ForkedFrom: "branch_d", + WantForkPoint: "branch_d_commit_1", + }}..., + ) + + assert.NoError(t, gw.Checkout("branch_wip", true)) + makeNamedCommit("branch_wip_commit_1") + makeNamedCommit("branch_wip_commit_2") + + tests = append(tests, []testcase{ + { + Commit: "branch_wip_commit_1", + ForkedFrom: "main", + WantForkPoint: "main_commit_4", + }, + { + Commit: "branch_wip_commit_2", + ForkedFrom: "main", + WantForkPoint: "main_commit_4", + }, + { + Ref: "branch_wip", + ForkedFrom: "main", + WantForkPoint: "main_commit_4", + }}..., + ) + + /* + * branch_wip_commit_2 + * branch_wip_commit_1 + / + * main_commit_4 + |\ + | * branch_c_commit_3 + | * branch_c_commit_2 + | |\ + | | * branch_d_commit_1 + | |/ + | * branch_c_commit_1 + |/ + | + | * branch_unmerged_commit_3 + | * branch_unmerged_commit_2 + | * branch_unmerged_commit_1 + |/ + * main_commit_3 + |\ + | * branch_b_commit_2 + | * branch_b_commit_1 + |/ + * main_commit_2 + |\ + | * branch_a_commit_2 + | * branch_a_commit_1 + |/ + * main_commit_1 + */ + + for _, tc := range tests { + assert.IsTrue(t, (tc.Commit != "") != (tc.Ref != ""), "set either commit or ref") + var rev string + if tc.Commit != "" { + rev = nameToHash[tc.Commit] + } else { + rev = tc.Ref + } + + wantName := tc.WantForkPoint + wantHash := nameToHash[wantName] + + gotHash, err := gw.FindNearestCommonParent(tc.ForkedFrom, rev) + assert.NoError(t, err, "FindNearestCommonParent") + gotName := hashToName[gotHash] + + assert.EqualStrings(t, wantHash, gotHash, + "fork point for %v (%v), want = %v (%v), got = %v (%v)", + tc.Commit, rev, wantName, wantHash, gotName, gotHash) + + gotIsMainCommit, err := gw.IsFirstParentAncestor(rev, "main") + assert.NoError(t, err, "IsFirstParentAncestor") + + if tc.WantIsMainCommit { + assert.IsTrue(t, gotIsMainCommit, + "%v (%v) is main commit", tc.Commit, rev) + } else { + assert.IsTrue(t, !gotIsMainCommit, + "%v (%v) is not main commit", tc.Commit, rev) + } + + } +} + const defaultBranch = "main" func mkOneCommitRepo(t *testing.T) string { diff --git a/test/sandbox/git.go b/test/sandbox/git.go index 8ae2c55bb..ee4c34c5f 100644 --- a/test/sandbox/git.go +++ b/test/sandbox/git.go @@ -278,6 +278,14 @@ func (git Git) BaseDir() string { return git.cfg.repoDir } +// LogGraph returns a human-readable graph of the git log +func (git Git) LogGraph() string { + git.t.Helper() + out, err := git.g.Exec("log", "--graph", "--all", "--oneline") + assert.NoError(git.t, err) + return out +} + func defaultGitConfig() GitConfig { return GitConfig{ LocalBranchName: "main",