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 }