github.com/Benchkram/bob@v0.0.0-20220321080157-7c8f3876e225/bobgit/status.go (about)

     1  package bobgit
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"fmt"
     7  	"io/fs"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	git "github.com/go-git/go-git/v5"
    13  
    14  	"github.com/Benchkram/bob/bobgit/status"
    15  	"github.com/Benchkram/bob/pkg/bobutil"
    16  	"github.com/Benchkram/bob/pkg/cmdutil"
    17  	"github.com/Benchkram/errz"
    18  )
    19  
    20  // Status executes `git status -porcelain` in all repositories
    21  // first level repositories found inside a .bob filtree.
    22  // It parses the output of each call and creates a object
    23  // containing status infos for all of them combined.
    24  // The result is similar to what `git status` would print
    25  // but visualy optimised for the multi repository case.
    26  func Status() (s *status.S, err error) {
    27  	defer errz.Recover(&err)
    28  
    29  	bobRoot, err := bobutil.FindBobRoot()
    30  	errz.Fatal(err)
    31  
    32  	depth, err := wdDepth(bobRoot)
    33  	errz.Fatal(err)
    34  
    35  	err = os.Chdir(bobRoot)
    36  	errz.Fatal(err)
    37  
    38  	// Assure toplevel is a git repo
    39  	isGit, err := isGitRepo(bobRoot)
    40  	errz.Fatal(err)
    41  	if !isGit {
    42  		return nil, ErrCouldNotFindGitDir
    43  	}
    44  
    45  	// search for git repos inside bobRoot/.
    46  	repoNames := []string{}
    47  	err = filepath.WalkDir(bobRoot, func(path string, d fs.DirEntry, err error) error {
    48  		if !d.IsDir() {
    49  			return nil
    50  		}
    51  
    52  		if d.Name() == ".git" {
    53  			p, err := filepath.Rel(bobRoot, filepath.Dir(path))
    54  			if err != nil {
    55  				return err
    56  			}
    57  			repoNames = append(repoNames, p)
    58  		}
    59  
    60  		for _, dir := range dontFollow {
    61  			if d.Name() == dir {
    62  				return fs.SkipDir
    63  			}
    64  		}
    65  
    66  		return nil
    67  	})
    68  	errz.Fatal(err)
    69  
    70  	s = status.New()
    71  	for _, name := range repoNames {
    72  
    73  		prefix := strings.Repeat("../", depth)
    74  
    75  		// repoPath is the path of the repo
    76  		// relative to the top bob repo.
    77  		repoPath := prefix + name
    78  		if name == "." {
    79  			repoPath = strings.TrimSuffix(repoPath, ".")
    80  		}
    81  		s.AddRepo(repoPath)
    82  
    83  		output, err := cmdutil.GitStatus(name)
    84  		errz.Fatal(err)
    85  
    86  		status, err := parse(output)
    87  		errz.Fatal(err)
    88  
    89  		// localpath is the path as given by `git status`
    90  		// in the respecting repo.
    91  		//
    92  		// TODO: compare and adapt with https://git-scm.com/docs/git-status#_short_format
    93  		for localpath, status := range status {
    94  			if status.Staging == git.Unmodified && status.Worktree == git.Unmodified {
    95  				continue
    96  			}
    97  
    98  			// Conflicts
    99  			// skip other checks if conflict happens for a file
   100  			if status.Staging == git.UpdatedButUnmerged || status.Worktree == git.UpdatedButUnmerged {
   101  				s.Conflicts[repoPath][localpath] = status
   102  				continue
   103  			}
   104  
   105  			// if deleted or added in both, add to conflicts and skip others
   106  			if (status.Staging == git.Deleted && status.Worktree == git.Deleted) || (status.Staging == git.Added && status.Worktree == git.Added) {
   107  				s.Conflicts[repoPath][localpath] = status
   108  				continue
   109  			}
   110  
   111  			// Staging aka index
   112  			if status.Staging == git.Renamed ||
   113  				status.Staging == git.Added ||
   114  				status.Staging == git.Deleted ||
   115  				status.Staging == git.Copied ||
   116  				status.Staging == git.Modified {
   117  				s.Staging[repoPath][localpath] = status
   118  			}
   119  
   120  			// Unstaged aka worktree
   121  			if status.Worktree == git.Modified ||
   122  				status.Worktree == git.Deleted {
   123  				s.Unstaged[repoPath][localpath] = status
   124  			}
   125  
   126  			// Untracked
   127  			if status.Worktree == git.Untracked && status.Staging == git.Untracked {
   128  				s.Untracked[repoPath][localpath] = status
   129  			}
   130  		}
   131  	}
   132  
   133  	s.Repos = append(s.Repos, repoNames...)
   134  
   135  	return s, nil
   136  }
   137  
   138  // parse `git status --porcelaine=v1` output
   139  // see https://git-scm.com/docs/git-status
   140  func parse(buf []byte) (status git.Status, err error) {
   141  	status = make(git.Status)
   142  
   143  	scanner := bufio.NewScanner(bytes.NewBuffer(buf))
   144  	for scanner.Scan() {
   145  		line := scanner.Text()
   146  		fileStatus := &git.FileStatus{}
   147  
   148  		fileStatus.Staging = readX(line)  // aka index
   149  		fileStatus.Worktree = readY(line) // worktree
   150  
   151  		path := line[3:]
   152  		if fileStatus.Staging == git.Renamed || fileStatus.Worktree == git.Renamed {
   153  			parts := strings.Split(path, " ")
   154  			fileStatus.Extra = parts[0] // name previous to rename
   155  			// parts[1] // ->
   156  			path = parts[2] // new name
   157  		}
   158  
   159  		status[path] = fileStatus
   160  	}
   161  
   162  	if scanner.Err() != nil {
   163  		return nil, scanner.Err()
   164  	}
   165  	return status, nil
   166  }
   167  
   168  func readX(line string) git.StatusCode {
   169  	return git.StatusCode(line[0])
   170  }
   171  
   172  func readY(line string) git.StatusCode {
   173  	return git.StatusCode(line[1])
   174  }
   175  
   176  // wdDepth returns the number of `../` traversals
   177  // till reaching dir.
   178  func wdDepth(dir string) (depth int, err error) {
   179  	defer errz.Recover(&err)
   180  
   181  	wd, err := os.Getwd()
   182  	errz.Fatal(err)
   183  
   184  	dir, err = filepath.Abs(dir)
   185  	errz.Fatal(err)
   186  
   187  	wdparts := len(strings.Split(wd, "/"))
   188  	dirparts := len(strings.Split(dir, "/"))
   189  
   190  	depth = wdparts - dirparts
   191  	if depth < 0 {
   192  		return 0, fmt.Errorf("wdDepth got a negative result")
   193  	}
   194  
   195  	return depth, nil
   196  }