Skip to content

Commit d90e2fe

Browse files
committedAug 14, 2022
add MergeGit to support git based merge
1 parent 9f17b16 commit d90e2fe

File tree

5 files changed

+391
-10
lines changed

5 files changed

+391
-10
lines changed
 

‎git/git.go

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"sync"
7+
8+
"github.com/xhd2015/go-coverage/sh"
9+
)
10+
11+
// find rename
12+
// git diff --find-renames --diff-filter=R HEAD~10 HEAD|grep -A 3 '^diff --git a/'|grep rename
13+
// FindRenames returns a mapping from new name to old name
14+
func FindRenames(dir string, oldCommit string, newCommit string) (map[string]string, error) {
15+
repo := &GitRepo{Dir: dir}
16+
return repo.FindRenames(oldCommit, newCommit)
17+
}
18+
19+
func FindUpdate(dir string, oldCommit string, newCommit string) ([]string, error) {
20+
repo := &GitRepo{Dir: dir}
21+
return repo.FindUpdate(oldCommit, newCommit)
22+
}
23+
24+
func FindUpdateAndRenames(dir string, oldCommit string, newCommit string) (map[string]string, error) {
25+
repo := &GitRepo{Dir: dir}
26+
updates, err := repo.FindUpdate(oldCommit, newCommit)
27+
if err != nil {
28+
return nil, err
29+
}
30+
m, err := repo.FindRenames(oldCommit, newCommit)
31+
if err != nil {
32+
return nil, err
33+
}
34+
for _, u := range updates {
35+
if _, ok := m[u]; ok {
36+
return nil, fmt.Errorf("invalid file: %s found both renamed and updated", u)
37+
}
38+
m[u] = u
39+
}
40+
return m, nil
41+
}
42+
43+
type GitRepo struct {
44+
Dir string
45+
}
46+
47+
func NewGitRepo(dir string) *GitRepo {
48+
return &GitRepo{
49+
Dir: dir,
50+
}
51+
}
52+
func NewSnapshot(dir string, commit string) *GitSnapshot {
53+
return &GitSnapshot{
54+
Dir: dir,
55+
Commit: commit,
56+
}
57+
}
58+
59+
func (c *GitRepo) FindUpdate(oldCommit string, newCommit string) ([]string, error) {
60+
cmd := fmt.Sprintf(`git -C %s diff --diff-filter=M --name-only %s %s`, sh.Quote(c.Dir), sh.Quote(getRef(oldCommit)), sh.Quote(getRef(newCommit)))
61+
stdout, _, err := sh.RunBashCmdOpts(cmd, sh.RunBashOptions{
62+
NeedStdOut: true,
63+
})
64+
if err != nil {
65+
return nil, err
66+
}
67+
return splitLinesFilterEmpty(stdout), nil
68+
}
69+
func (c *GitRepo) FindRenames(oldCommit string, newCommit string) (map[string]string, error) {
70+
// example:
71+
// $ git diff --find-renames --diff-filter=R HEAD~10 HEAD
72+
// diff --git a/test/stubv2/boot/boot.go b/test/stub/boot/boot.go
73+
// similarity index 94%
74+
// rename from test/stubv2/boot/boot.go
75+
// rename to test/stub/boot/boot.go
76+
// index e0e86051..56c49801 100644
77+
// --- a/test/stubv2/boot/boot.go
78+
// +++ b/test/stub/boot/boot.go
79+
// @@ -4,8 +4,10 @@ import (
80+
cmd := fmt.Sprintf(`git -C %s diff --find-renames --diff-filter=R %s %s|grep -A 3 '^diff --git a/'|grep rename`, sh.Quote(c.Dir), sh.Quote(getRef(oldCommit)), sh.Quote(getRef(newCommit)))
81+
stdout, _, err := sh.RunBashCmdOpts(cmd, sh.RunBashOptions{
82+
// Verbose: true,
83+
NeedStdOut: true,
84+
})
85+
if err != nil {
86+
return nil, err
87+
}
88+
lines := splitLinesFilterEmpty(stdout)
89+
if len(lines)%2 != 0 {
90+
return nil, fmt.Errorf("internal error, expect git return rename pairs, found:%d", len(lines))
91+
}
92+
93+
m := make(map[string]string, len(lines)/2)
94+
for i := 0; i < len(lines); i += 2 {
95+
from := strings.TrimPrefix(lines[i], "rename from ")
96+
to := strings.TrimPrefix(lines[i+1], "rename to ")
97+
98+
m[to] = from
99+
}
100+
return m, nil
101+
}
102+
103+
type GitSnapshot struct {
104+
Dir string
105+
Commit string
106+
107+
filesInit sync.Once
108+
files []string
109+
filesErr error
110+
fileMap map[string]bool
111+
}
112+
113+
func (c *GitSnapshot) GetContent(file string) (string, error) {
114+
normFile := strings.TrimPrefix(file, "./")
115+
if normFile == "" {
116+
return "", fmt.Errorf("invalid file:%v", file)
117+
}
118+
if !c.fileMap[normFile] {
119+
return "", fmt.Errorf("not a file, maybe a dir:%v", file)
120+
}
121+
122+
content, _, err := sh.RunBashWithOpts([]string{
123+
fmt.Sprintf("git -C %s cat-file -p %s:%s", sh.Quote(c.Dir), sh.Quote(c.ref()), sh.Quote(normFile)),
124+
}, sh.RunBashOptions{
125+
NeedStdOut: true,
126+
})
127+
return content, err
128+
}
129+
func (c *GitSnapshot) ListFiles() ([]string, error) {
130+
c.filesInit.Do(func() {
131+
stdout, _, err := sh.RunBashWithOpts([]string{
132+
fmt.Sprintf("git -C %s ls-files --with-tree %s", sh.Quote(c.Dir), sh.Quote(c.ref())),
133+
}, sh.RunBashOptions{
134+
Verbose: true,
135+
NeedStdOut: true,
136+
})
137+
if err != nil {
138+
c.filesErr = err
139+
return
140+
}
141+
c.files = splitLinesFilterEmpty(stdout)
142+
c.fileMap = make(map[string]bool)
143+
for _, e := range c.files {
144+
c.fileMap[e] = true
145+
}
146+
})
147+
return c.files, c.filesErr
148+
}
149+
150+
func (c *GitSnapshot) ref() string {
151+
return getRef(c.Commit)
152+
}
153+
154+
func getRef(commit string) string {
155+
if commit == "" {
156+
return "HEAD"
157+
}
158+
return commit
159+
}
160+
161+
func splitLinesFilterEmpty(s string) []string {
162+
list := strings.Split(s, "\n")
163+
idx := 0
164+
for _, e := range list {
165+
if e != "" {
166+
list[idx] = e
167+
idx++
168+
}
169+
}
170+
return list[:idx]
171+
}

‎git/git_test.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package git
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
var dir string
9+
10+
func init() {
11+
dir = os.Getenv("TEST_DIR")
12+
}
13+
14+
// go test -run TestFindUpdate -v ./git
15+
func TestFindUpdate(t *testing.T) {
16+
files, err := FindUpdate(dir, "HEAD~10", "HEAD")
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
t.Logf("files:%v", files)
21+
}
22+
23+
// go test -run TestFindRename -v ./git
24+
func TestFindRename(t *testing.T) {
25+
files, err := FindRenames(dir, "HEAD~10", "HEAD")
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
t.Logf("files:%v", files)
30+
}
31+
32+
// go test -run FindUpdateAndRenames -v ./git
33+
func TestFindUpdateAndRenames(t *testing.T) {
34+
files, err := FindUpdateAndRenames(dir, "HEAD~10", "HEAD")
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
t.Logf("files:%v", files)
39+
}
40+
41+
// go test -run TestGitSnapshot -v ./git
42+
func TestGitSnapshot(t *testing.T) {
43+
git := &GitSnapshot{
44+
Dir: dir,
45+
Commit: "HEAD",
46+
}
47+
files, err := git.ListFiles()
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
t.Logf("files:%v", files)
52+
53+
content, err := git.GetContent(files[0])
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
t.Logf("content:%v", content)
58+
}

‎merge/merge.go

+43-3
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,63 @@ package merge
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/xhd2015/go-coverage/code"
78
"github.com/xhd2015/go-coverage/cover"
89
diff "github.com/xhd2015/go-coverage/diff/myers"
10+
"github.com/xhd2015/go-coverage/git"
911
"github.com/xhd2015/go-coverage/profile"
1012
)
1113

14+
func MergeGit(old *profile.Profile, new *profile.Profile, modPrefix string, dir string, oldCommit string, newCommit string) (*profile.Profile, error) {
15+
newToOld, err := git.FindUpdateAndRenames(dir, oldCommit, newCommit)
16+
if err != nil {
17+
return nil, err
18+
}
19+
20+
oldGit := git.NewSnapshot(dir, oldCommit)
21+
newGit := git.NewSnapshot(dir, newCommit)
22+
getOldFile := func(newFile string) string {
23+
file := strings.TrimPrefix(newFile, modPrefix)
24+
file = strings.TrimPrefix(file, "/")
25+
file = strings.TrimPrefix(file, ".")
26+
return newToOld[file]
27+
}
28+
29+
return Merge(old, oldGit.GetContent, new, newGit.GetContent, MergeOptions{
30+
GetOldFile: getOldFile,
31+
})
32+
}
33+
34+
type MergeOptions struct {
35+
GetOldFile func(newFile string) string
36+
}
37+
1238
// Merge merge 2 profiles with their code diffs
13-
func Merge(old *profile.Profile, oldCodeGetter func(f string) (string, error), new *profile.Profile, newCodeGetter func(f string) (string, error)) (*profile.Profile, error) {
39+
func Merge(old *profile.Profile, oldCodeGetter func(f string) (string, error), new *profile.Profile, newCodeGetter func(f string) (string, error), opts MergeOptions) (*profile.Profile, error) {
1440
oldCouners := old.Counters()
1541
newCounters := new.Counters()
1642

1743
mergedCounters := make(map[string][]int, len(newCounters))
1844
for file, newCounter := range newCounters {
19-
oldCounter, ok := oldCouners[file]
45+
var oldMustExist bool
46+
oldFile := file
47+
if opts.GetOldFile != nil {
48+
oldFile = opts.GetOldFile(file)
49+
if oldFile == "" {
50+
mergedCounters[file] = newCounter
51+
continue
52+
}
53+
oldMustExist = true
54+
}
55+
56+
oldCounter, ok := oldCouners[oldFile]
2057
if !ok {
21-
// TODO: detect file rename
58+
if oldMustExist {
59+
return nil, fmt.Errorf("counters not found for old file %s", oldFile)
60+
}
61+
mergedCounters[file] = newCounter
2262
continue
2363
}
2464
oldCode, err := oldCodeGetter(file)

‎merge/merge_test.go

+9-7
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ func testMergeProfile(t *testing.T, oldFn string, oldSrcFile string, newFn strin
6262
if err != nil {
6363
t.Fatal(err)
6464
}
65-
// change old file to new file
66-
for _, block := range oldProfile.Blocks {
67-
if strings.HasSuffix(block.FileName, oldSrcFile) {
68-
block.FileName = block.FileName[:len(block.FileName)-len(oldSrcFile)] + newSrcFile
69-
}
70-
}
7165

7266
newProfile, err := profile.ParseProfileFile(newCover)
7367
if err != nil {
@@ -89,7 +83,15 @@ func testMergeProfile(t *testing.T, oldFn string, oldSrcFile string, newFn strin
8983
return readString(newSrcFile)
9084
}
9185

92-
mergedProfile, err := Merge(oldProfile, oldCodeGetter, newProfile, newCodeGetter)
86+
mergedProfile, err := Merge(oldProfile, oldCodeGetter, newProfile, newCodeGetter, MergeOptions{
87+
GetOldFile: func(newFile string) string {
88+
// map new file to old file
89+
if strings.HasSuffix(newFile, newSrcFile) {
90+
return newFile[:len(newFile)-len(newSrcFile)] + oldSrcFile
91+
}
92+
return ""
93+
},
94+
})
9395
if err != nil {
9496
t.Fatal(err)
9597
}

‎sh/sh.go

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package sh
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"os/exec"
9+
"strings"
10+
)
11+
12+
func RunBash(cmdList []string, verbose bool) error {
13+
_, _, err := RunBashWithOpts(cmdList, RunBashOptions{
14+
Verbose: verbose,
15+
})
16+
return err
17+
}
18+
19+
type RunBashOptions struct {
20+
Verbose bool
21+
NeedStdErr bool
22+
NeedStdOut bool
23+
24+
ErrExcludeCmd bool
25+
26+
// if StdoutToJSON != nil, the value is parsed into this struct
27+
StdoutToJSON interface{}
28+
}
29+
30+
func RunBashWithOpts(cmdList []string, opts RunBashOptions) (stdout string, stderr string, err error) {
31+
return RunBashCmdOpts(bashCommandExpr(cmdList), opts)
32+
}
33+
func RunBashCmdOpts(cmdExpr string, opts RunBashOptions) (stdout string, stderr string, err error) {
34+
if opts.Verbose {
35+
log.Printf("%s", cmdExpr)
36+
}
37+
cmd := exec.Command("bash", "-c", cmdExpr)
38+
stdoutBuf := bytes.NewBuffer(nil)
39+
stderrBuf := bytes.NewBuffer(nil)
40+
cmd.Stdout = stdoutBuf
41+
cmd.Stderr = stderrBuf
42+
err = cmd.Run()
43+
if err != nil {
44+
cmdDetail := ""
45+
if !opts.ErrExcludeCmd {
46+
cmdDetail = fmt.Sprintf("cmd %s ", cmdExpr)
47+
}
48+
err = fmt.Errorf("running cmd error: %s%v stdout:%s stderr:%s", cmdDetail, err, stdoutBuf.String(), stderrBuf.String())
49+
return
50+
}
51+
if opts.NeedStdOut {
52+
stdout = stdoutBuf.String()
53+
}
54+
if opts.NeedStdErr {
55+
stderr = stderrBuf.String()
56+
}
57+
if opts.StdoutToJSON != nil {
58+
err = json.Unmarshal(stdoutBuf.Bytes(), opts.StdoutToJSON)
59+
if err != nil {
60+
err = fmt.Errorf("parse command output to %T error:%v", opts.StdoutToJSON, err)
61+
}
62+
}
63+
return
64+
}
65+
66+
func JoinArgs(args []string) string {
67+
eArgs := make([]string, 0, len(args))
68+
for _, arg := range args {
69+
eArgs = append(eArgs, Quote(arg))
70+
}
71+
return strings.Join(eArgs, " ")
72+
}
73+
74+
func Quotes(args ...string) string {
75+
eArgs := make([]string, 0, len(args))
76+
for _, arg := range args {
77+
eArgs = append(eArgs, Quote(arg))
78+
}
79+
return strings.Join(eArgs, " ")
80+
}
81+
func Quote(s string) string {
82+
if s == "" {
83+
return "''"
84+
}
85+
if strings.ContainsAny(s, "\t \n;<>\\${}()&!") { // special args
86+
s = strings.ReplaceAll(s, "'", "'\\''")
87+
return "'" + s + "'"
88+
}
89+
return s
90+
}
91+
92+
func bashCommandExpr(cmd []string) string {
93+
var b strings.Builder
94+
for i, c := range cmd {
95+
c = strings.TrimSpace(c)
96+
if c == "" {
97+
continue
98+
}
99+
b.WriteString(c)
100+
if i >= len(cmd)-1 {
101+
// last no \n
102+
continue
103+
}
104+
if strings.HasSuffix(c, "\n") || strings.HasSuffix(c, "&&") || strings.HasSuffix(c, ";") || strings.HasSuffix(c, "||") {
105+
continue
106+
}
107+
b.WriteString("\n")
108+
}
109+
return strings.Join(cmd, "\n")
110+
}

0 commit comments

Comments
 (0)
Please sign in to comment.