github.com/grantbow/fit@v0.7.1-0.20220916164603-1f7c88ac81e6/scm/GitManager.go (about) 1 package scm 2 3 import ( 4 "bytes" 5 "errors" 6 "fmt" 7 bugs "github.com/grantbow/fit/issues" 8 "io/ioutil" 9 "os" 10 "os/exec" 11 "regexp" 12 "strings" 13 ) 14 15 //var dops = bugs.Directory(os.PathSeparator) 16 //var sops = string(os.PathSeparator) 17 18 // GitManager type has fields Autoclose and UseBugPrefix. 19 type GitManager struct { 20 Autoclose bool 21 UseBugPrefix bool 22 } 23 24 // Purge runs git clean -fd on the directory containing the fit directory. 25 func (mgr GitManager) Purge(dir bugs.Directory) error { 26 cmd := exec.Command("git", "clean", "-fd", string(dir)+sops) 27 28 cmd.Stdin = os.Stdin 29 cmd.Stdout = os.Stdout 30 cmd.Stderr = os.Stderr 31 return cmd.Run() 32 } 33 34 type issueStatus struct { 35 a, d, m bool // Added, Deleted, Modified 36 } 37 38 type issuesStatus map[string]issueStatus 39 40 // Get list of created, updated, closed and closed-on-github issues. 41 // 42 // In general following rules to categorize issues are applied: 43 // * closed if Description file is deleted (D); 44 // * created if Description file is created (A) (TODO: handle issue renamings); 45 // * closed issue will also close issue on GH when Autoclose is true (see Identifier example); 46 // * updated if Description file is modified (M); 47 // * updated if Description is unchanged but any other files are touched. (' '+x) 48 // 49 // eg output from `from git status --porcelain`, appendix mine 50 // note that `git add -A issues` was invoked before 51 // 52 // D issues/First-GH-issue/Description issue closed (GH issues are also here) 53 // D issues/First-GH-issue/Identifier maybe it is GH issue, maybe not 54 // M issues/issue--2/Description desc updated 55 // A issues/issue--2/Status new field added (status); considered as update unless Description is also created 56 // D issues/issue1/Description issue closed 57 // A issues/issue3/Description new issue, description field is mandatory for rich format 58 59 func (mgr GitManager) currentStatus(dir bugs.Directory, config bugs.Config) (closedOnGitHub []string, _ issuesStatus) { 60 ghRegex := regexp.MustCompile("(?im)^-Github:(.*)$") 61 closesGH := func(file string) (issue string, ok bool) { 62 if !mgr.Autoclose { 63 return "", false 64 } 65 if !strings.HasSuffix(file, "Identifier") { 66 return "", false 67 } 68 diff := exec.Command("git", "diff", "--staged", "--", file) 69 diffout, _ := diff.CombinedOutput() 70 matches := ghRegex.FindStringSubmatch(string(diffout)) 71 if len(matches) > 1 { 72 return strings.TrimSpace(matches[1]), true 73 } 74 return "", false 75 } 76 short := func(path string) string { 77 beg := strings.Index(path, sops) 78 end := strings.LastIndex(path, sops) 79 if beg+1 >= end { 80 return "???" 81 } 82 return path[beg+1 : end] 83 } 84 85 cmd := exec.Command("git", "status", "-z", "--porcelain", string(dir)) 86 out, _ := cmd.CombinedOutput() 87 files := strings.Split(string(out), "\000") 88 89 issues := issuesStatus{} 90 var ghClosed []string 91 const minLineLen = 3 /*for path*/ + 2 /*for issues dir with path sep*/ + 3 /*for issue name, path sep and any file under issue dir*/ 92 for _, file := range files { 93 if len(file) < minLineLen { 94 continue 95 } 96 97 path := file[3:] 98 op := file[0] 99 desc := strings.HasSuffix(path, sops+config.DescriptionFileName) 100 name := short(path) 101 issue := issues[name] 102 103 switch { 104 case desc && op == 'D': 105 issue.d = true 106 case desc && op == 'A': 107 issue.a = true 108 default: 109 issue.m = true 110 if op == 'D' { 111 if ghIssue, ok := closesGH(path); ok { 112 ghClosed = append(ghClosed, ghIssue) 113 issue.d = true // to be sure 114 } 115 } 116 } 117 118 issues[name] = issue 119 } 120 return ghClosed, issues 121 } 122 123 // Create commit message by iterating over issues in order: 124 // closed issues are most important (something is DONE, ok? ;), those issues will also become hidden) 125 // new issues are next, with just updates at the end 126 // TODO: do something if this message will be too long 127 func (mgr GitManager) commitMsg(dir bugs.Directory, config bugs.Config) []byte { 128 ghClosed, issues := mgr.currentStatus(dir, config) 129 130 done, add, update, together := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} 131 var cntd, cnta, cntu int 132 133 for issue, state := range issues { 134 if state.d { 135 fmt.Fprintf(done, ", %q", issue) 136 cntd++ 137 } else if state.a { 138 fmt.Fprintf(add, ", %q", issue) 139 cnta++ 140 } else if state.m { 141 fmt.Fprintf(update, ", %q", issue) 142 cntu++ 143 } 144 } 145 146 outf := func(buf *bytes.Buffer, what string, many bool) { 147 if buf.Len() == 0 { 148 return 149 } 150 var plural string 151 if many { 152 plural = "s:" 153 } 154 item := buf.Bytes()[2:] 155 fmt.Fprintf(together, "%s issue%s %s; ", what, plural, item) 156 } 157 outf(done, "Close", cntd > 1) 158 outf(add, "Create", cnta > 1) 159 outf(update, "Update", cntu > 1) 160 if l := together.Len(); l > 0 { 161 together.Truncate(l - 2) // "; " from last applied outf() 162 } 163 164 if len(ghClosed) > 0 { 165 fmt.Fprintf(together, "\n\nCloses %s\n", strings.Join(ghClosed, ", closes ")) 166 } 167 return together.Bytes() 168 } 169 170 // Commit saves files to the SCM. It runs git add -A. 171 func (mgr GitManager) Commit(dir bugs.Directory, backupCommitMsg string, config bugs.Config) error { 172 cmd := exec.Command("git", "add", "-A", string(dir)) 173 if err := cmd.Run(); err != nil { 174 fmt.Printf("Could not add issues to be committed: %s?\n", err.Error()) 175 return err 176 } 177 178 msg := mgr.commitMsg(dir, config) 179 180 file, err := ioutil.TempFile("", "bugCommit") 181 if err != nil { 182 fmt.Fprintf(os.Stderr, "Could not create temporary file.\nNothing committed.\n") 183 return err 184 } 185 defer os.Remove(file.Name()) 186 187 if len(msg) == 0 { 188 fmt.Fprintf(file, "%s\n", backupCommitMsg) 189 } else { 190 var pref string 191 if mgr.UseBugPrefix { 192 pref = "issue: " 193 } 194 fmt.Fprintf(file, "%s%s\n", pref, msg) 195 } 196 //fmt.Print("debug commit : git", "commit", "-o", string(dir), "-F", file.Name(), "-q\n") 197 cmd = exec.Command("git", "commit", "-o", string(dir), "-F", file.Name(), "-q") 198 if err := cmd.Run(); err != nil { 199 // If nothing was added commit will have an error. 200 // in some cases we didn't care, it just meant there's nothing to commit. 201 // the stdout to test could be captured 202 //fmt.Printf("No new issues committed.\n") // assumed this error incorrectly, same for HgManager 203 fmt.Printf("git commit error %v\n", err.Error()) // $? 204 return err 205 } 206 return nil 207 } 208 209 // SCMTyper returns "git". 210 func (mgr GitManager) SCMTyper() string { 211 return "git" 212 } 213 214 // SCMIssuesUpdaters returns []byte of uncommitted files staged AND working directory 215 func (mgr GitManager) SCMIssuesUpdaters(config bugs.Config) ([]byte, error) { 216 cmd := exec.Command("git", "status", "--porcelain", "-u", "--", ":"+sops+config.FitDirName) 217 // --porcelain output format 218 // -u shows all unstaged files, not just directories 219 // after -- the path is ":"+sops+"issues" 220 // 221 // previously 222 //cmd := exec.Command("git", "status", "--porcelain", "-u", "issues", "\":(top)\"") 223 // the ":(top)" was used for full paths when not at the git root directory 224 // then 225 //cmd := exec.Command("git", "status", "--porcelain", "-u", "--", ":/issues") 226 // need to test for windows / vs \ as path separator 227 228 co, _ := cmd.CombinedOutput() 229 if string(co) == "" { 230 return []byte(""), nil 231 } 232 return co, errors.New("Files In " + config.FitDirName + "/ Need Committing") 233 } 234 235 // SCMIssuesCacher returns []byte of uncommitted files staged NOT working directory 236 func (mgr GitManager) SCMIssuesCacher(config bugs.Config) ([]byte, error) { // config bugs.Config 237 cmd := exec.Command("git", "diff", "--name-status", "--cached", "HEAD", "--", ":"+sops+config.FitDirName) 238 // only whitespace differs from output of git status 239 co, _ := cmd.CombinedOutput() 240 if string(co) == "" { 241 return []byte(""), nil 242 } 243 return co, errors.New("Files In " + config.FitDirName + "/ Staged and Need Committing") 244 } 245 246 //func (mgr GitManager) SCMChangedIssues() ([]byte, error) { 247 //output from SCMIssuesCacher(), accept unique first directory level of the file list 248 //then check if updates should be sent for these issues 249 // then send the updates 250 //}