github.com/driusan/bug@v0.3.2-0.20190306121946-d7f4e7f33fea/scm/GitManager.go (about) 1 package scm 2 3 import ( 4 "bytes" 5 "fmt" 6 "github.com/driusan/bug/bugs" 7 "io/ioutil" 8 "os" 9 "os/exec" 10 "regexp" 11 "strings" 12 ) 13 14 type GitManager struct { 15 Autoclose bool 16 UseBugPrefix bool 17 } 18 19 func (a GitManager) Purge(dir bugs.Directory) error { 20 cmd := exec.Command("git", "clean", "-fd", string(dir)) 21 22 cmd.Stdin = os.Stdin 23 cmd.Stdout = os.Stdout 24 cmd.Stderr = os.Stderr 25 return cmd.Run() 26 } 27 28 type issueStatus struct { 29 a, d, m bool // Added, Deleted, Modified 30 } 31 type issuesStatus map[string]issueStatus 32 33 // Get list of created, updated, closed and closed-on-github issues. 34 // 35 // In general following rules to categorize issues are applied: 36 // * closed if Description file is deleted (D); 37 // * created if Description file is created (A) (TODO: handle issue renamings); 38 // * closed issue will also close issue on GH when Autoclose is true (see Identifier example); 39 // * updated if Description file is modified (M); 40 // * updated if Description is unchanged but any other files are touched. (' '+x) 41 // 42 // eg output from `from git status --porcelain`, appendix mine 43 // note that `git add -A issues` was invoked before 44 // 45 // D issues/First-GH-issue/Description issue closed (GH issues are also here) 46 // D issues/First-GH-issue/Identifier maybe it is GH issue, maybe not 47 // M issues/issue--2/Description desc updated 48 // A issues/issue--2/Status new field added (status); considered as update unless Description is also created 49 // D issues/issue1/Description issue closed 50 // A issues/issue3/Description new issue, description field is mandatory for rich format 51 func (a GitManager) currentStatus(dir bugs.Directory) (closedOnGitHub []string, _ issuesStatus) { 52 ghRegex := regexp.MustCompile("(?im)^-Github:(.*)$") 53 closesGH := func(file string) (issue string, ok bool) { 54 if !a.Autoclose { 55 return "", false 56 } 57 if !strings.HasSuffix(file, "Identifier") { 58 return "", false 59 } 60 diff := exec.Command("git", "diff", "--staged", "--", file) 61 diffout, _ := diff.CombinedOutput() 62 matches := ghRegex.FindStringSubmatch(string(diffout)) 63 if len(matches) > 1 { 64 return strings.TrimSpace(matches[1]), true 65 } 66 return "", false 67 } 68 short := func(path string) string { 69 b := strings.Index(path, "/") 70 e := strings.LastIndex(path, "/") 71 if b+1 >= e { 72 return "???" 73 } 74 return path[b+1 : e] 75 } 76 77 cmd := exec.Command("git", "status", "-z", "--porcelain", string(dir)) 78 out, _ := cmd.CombinedOutput() 79 files := strings.Split(string(out), "\000") 80 81 issues := issuesStatus{} 82 var ghClosed []string 83 const minLineLen = 3 /*for path*/ + 2 /*for issues dir with path sep*/ + 3 /*for issue name, path sep and any file under issue dir*/ 84 for _, file := range files { 85 if len(file) < minLineLen { 86 continue 87 } 88 89 path := file[3:] 90 op := file[0] 91 desc := strings.HasSuffix(path, "/Description") 92 name := short(path) 93 issue := issues[name] 94 95 switch { 96 case desc && op == 'D': 97 issue.d = true 98 case desc && op == 'A': 99 issue.a = true 100 default: 101 issue.m = true 102 if op == 'D' { 103 if ghIssue, ok := closesGH(path); ok { 104 ghClosed = append(ghClosed, ghIssue) 105 issue.d = true // to be sure 106 } 107 } 108 } 109 110 issues[name] = issue 111 } 112 return ghClosed, issues 113 } 114 115 // Create commit message by iterate over issues in order: 116 // closed issues are most important (something is DONE, ok? ;), those issues will also become hidden) 117 // new issues are next, with just updates at the end 118 // TODO: do something if this message will be too long 119 func (a GitManager) commitMsg(dir bugs.Directory) []byte { 120 ghClosed, issues := a.currentStatus(dir) 121 122 done, add, update, together := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} 123 var cntd, cnta, cntu int 124 125 for issue, state := range issues { 126 if state.d { 127 fmt.Fprintf(done, ", %q", issue) 128 cntd++ 129 } else if state.a { 130 fmt.Fprintf(add, ", %q", issue) 131 cnta++ 132 } else if state.m { 133 fmt.Fprintf(update, ", %q", issue) 134 cntu++ 135 } 136 } 137 138 f := func(b *bytes.Buffer, what string, many bool) { 139 if b.Len() == 0 { 140 return 141 } 142 var m string 143 if many { 144 m = "s:" 145 } 146 s := b.Bytes()[2:] 147 fmt.Fprintf(together, "%s issue%s %s; ", what, m, s) 148 } 149 f(done, "Close", cntd > 1) 150 f(add, "Create", cnta > 1) 151 f(update, "Update", cntu > 1) 152 if l := together.Len(); l > 0 { 153 together.Truncate(l - 2) // "; " from last applied f() 154 } 155 156 if len(ghClosed) > 0 { 157 fmt.Fprintf(together, "\n\nCloses %s\n", strings.Join(ghClosed, ", closes ")) 158 } 159 return together.Bytes() 160 } 161 162 func (a GitManager) Commit(dir bugs.Directory, backupCommitMsg string) error { 163 cmd := exec.Command("git", "add", "-A", string(dir)) 164 if err := cmd.Run(); err != nil { 165 fmt.Printf("Could not add issues to be commited: %s?\n", err.Error()) 166 return err 167 168 } 169 170 msg := a.commitMsg(dir) 171 172 file, err := ioutil.TempFile("", "bugCommit") 173 if err != nil { 174 fmt.Fprintf(os.Stderr, "Could not create temporary file.\nNothing commited.\n") 175 return err 176 } 177 defer os.Remove(file.Name()) 178 179 if len(msg) == 0 { 180 fmt.Fprintf(file, "%s\n", backupCommitMsg) 181 } else { 182 var pref string 183 if a.UseBugPrefix { 184 pref = "bug: " 185 } 186 fmt.Fprintf(file, "%s%s\n", pref, msg) 187 } 188 cmd = exec.Command("git", "commit", "-o", string(dir), "-F", file.Name(), "-q") 189 if err := cmd.Run(); err != nil { 190 // If nothing was added commit will have an error, 191 // but we don't care it just means there's nothing 192 // to commit. 193 fmt.Printf("No new issues commited\n") 194 return nil 195 } 196 return nil 197 } 198 199 func (a GitManager) GetSCMType() string { 200 return "git" 201 }