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  //}