| @@ -52,23 +52,78 @@ const ( |
| 52 | 52 | reasonNoBase = "the commit it was made against is no longer available" |
| 53 | 53 | ) |
| 54 | 54 | |
| 55 | | // readAnchored reads the regular file path at commit, with its mode. |
| 56 | | func readAnchored(dir, commit, path string) ([]byte, string, error) { |
| 57 | | e, ok := gitutil.StatPath(dir, commit, path) |
| 58 | | if !ok { |
| 59 | | return nil, "", fmt.Errorf("%s", reasonGone) |
| 55 | // anchoredFiles reads the files suggestions anchor in, for one page or |
| 56 | // listing: one ls-tree per (commit, path), and every blob through one |
| 57 | // cat-file --batch process started on first use, so a merge request with |
| 58 | // many suggestions on a file costs no more processes than one with a |
| 59 | // single suggestion. |
| 60 | type anchoredFiles struct { |
| 61 | dir string |
| 62 | entries map[[2]string]anchoredEntry |
| 63 | blobs map[string][]byte |
| 64 | batch *gitutil.BlobBatch |
| 65 | spawned int // git processes started, for the test that bounds it |
| 66 | } |
| 67 | |
| 68 | type anchoredEntry struct { |
| 69 | e gitutil.TreeEntry |
| 70 | ok bool |
| 71 | } |
| 72 | |
| 73 | func newAnchoredFiles(dir string) *anchoredFiles { |
| 74 | return &anchoredFiles{dir: dir, entries: map[[2]string]anchoredEntry{}, blobs: map[string][]byte{}} |
| 75 | } |
| 76 | |
| 77 | func (f *anchoredFiles) close() { |
| 78 | if f.batch != nil { |
| 79 | f.batch.Close() |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // read returns the regular file path at commit, with its mode and blob id. |
| 84 | func (f *anchoredFiles) read(commit, path string) ([]byte, string, string, error) { |
| 85 | key := [2]string{commit, path} |
| 86 | ent, seen := f.entries[key] |
| 87 | if !seen { |
| 88 | f.spawned++ |
| 89 | ent.e, ent.ok = gitutil.StatPath(f.dir, commit, path) |
| 90 | f.entries[key] = ent |
| 91 | } |
| 92 | e := ent.e |
| 93 | if !ent.ok { |
| 94 | return nil, "", "", fmt.Errorf("%s", reasonGone) |
| 60 | 95 | } |
| 61 | 96 | if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") { |
| 62 | | return nil, "", fmt.Errorf("%s is not a regular file", path) |
| 97 | return nil, "", "", fmt.Errorf("%s is not a regular file", path) |
| 63 | 98 | } |
| 64 | 99 | if e.Size > maxSuggestionBytes { |
| 65 | | return nil, "", fmt.Errorf("%s is larger than %d bytes", path, maxSuggestionBytes) |
| 100 | return nil, "", "", fmt.Errorf("%s is larger than %d bytes", path, maxSuggestionBytes) |
| 101 | } |
| 102 | if content, ok := f.blobs[e.SHA]; ok { |
| 103 | return content, e.Mode, e.SHA, nil |
| 104 | } |
| 105 | if f.batch == nil { |
| 106 | b, err := gitutil.NewBlobBatch(f.dir) |
| 107 | if err != nil { |
| 108 | return nil, "", "", err |
| 109 | } |
| 110 | f.spawned++ |
| 111 | f.batch = b |
| 66 | 112 | } |
| 67 | | content, err := gitutil.ReadBlob(dir, commit, path, maxSuggestionBytes) |
| 113 | content, err := f.batch.Read(e.SHA, maxSuggestionBytes) |
| 68 | 114 | if err != nil { |
| 69 | | return nil, "", err |
| 115 | return nil, "", "", err |
| 70 | 116 | } |
| 71 | | return content, e.Mode, nil |
| 117 | f.blobs[e.SHA] = content |
| 118 | return content, e.Mode, e.SHA, nil |
| 119 | } |
| 120 | |
| 121 | // readAnchored reads the regular file path at commit, with its mode. |
| 122 | func readAnchored(dir, commit, path string) ([]byte, string, error) { |
| 123 | f := newAnchoredFiles(dir) |
| 124 | defer f.close() |
| 125 | content, mode, _, err := f.read(commit, path) |
| 126 | return content, mode, err |
| 72 | 127 | } |
| 73 | 128 | |
| 74 | 129 | // suggestionRange is a thread's first and last line. |
| @@ -76,45 +131,58 @@ func suggestionRange(cm store.DiffComment) (int, int) { |
| 76 | 131 | return int(firstNonZero(cm.StartLine, cm.Line)), int(cm.Line) |
| 77 | 132 | } |
| 78 | 133 | |
| 79 | | // ThreadSuggestion is the suggestion a thread root carries, checked |
| 80 | | // against the merge request's head, or nil when it carries none. The |
| 134 | // Suggestions are the suggestions the thread roots among comments carry, |
| 135 | // by thread id, each checked against the merge request's head. The |
| 81 | 136 | // original lines are read from the commit the comment was made on, in |
| 82 | 137 | // the target repository, which holds every head the merge request has |
| 83 | 138 | // had until gc prunes an abandoned one. |
| 84 | | func ThreadSuggestion(st *store.Store, root string, repo store.Repo, mr store.MR, cm store.DiffComment) *SuggestionOut { |
| 85 | | if cm.ReplyTo != 0 || cm.Side != "new" { |
| 86 | | return nil |
| 87 | | } |
| 88 | | lines, found, err := suggest.Parse(cm.Body) |
| 89 | | if err != nil || !found { |
| 90 | | return nil |
| 91 | | } |
| 92 | | start, end := suggestionRange(cm) |
| 93 | | s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA, |
| 94 | | Replacement: suggest.Text(lines), Apply: "server"} |
| 95 | | if signedOnly(st, repo, mr) { |
| 96 | | s.Apply = "local" |
| 97 | | } |
| 98 | | dir := RepoDir(root, repo.OwnerName, repo.Name) |
| 99 | | if e, ok := gitutil.StatPath(dir, cm.HeadSHA, cm.Path); ok { |
| 100 | | s.Blob = e.SHA |
| 101 | | } |
| 102 | | base, _, err := readAnchored(dir, cm.HeadSHA, cm.Path) |
| 103 | | orig, ok := suggest.Range(base, start, end) |
| 104 | | if err != nil || !ok { |
| 105 | | s.Outdated, s.Reason = true, reasonNoBase |
| 106 | | return s |
| 107 | | } |
| 108 | | s.Original = string(orig) |
| 109 | | s.Reason = anchorReason(dir, mr.HeadSHA, s) |
| 110 | | s.Outdated = s.Reason != "" |
| 111 | | return s |
| 139 | func Suggestions(st *store.Store, root string, repo store.Repo, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut { |
| 140 | f := newAnchoredFiles(RepoDir(root, repo.OwnerName, repo.Name)) |
| 141 | defer f.close() |
| 142 | return suggestionsWith(f, func() bool { return signedOnly(st, repo, mr) }, mr, comments) |
| 143 | } |
| 144 | |
| 145 | // suggestionsWith is Suggestions reading through f. signed is asked |
| 146 | // once, and only when there is a suggestion. |
| 147 | func suggestionsWith(f *anchoredFiles, signed func() bool, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut { |
| 148 | out := map[int64]*SuggestionOut{} |
| 149 | apply := "" |
| 150 | for _, cm := range comments { |
| 151 | if cm.ReplyTo != 0 || cm.Side != "new" { |
| 152 | continue |
| 153 | } |
| 154 | lines, found, err := suggest.Parse(cm.Body) |
| 155 | if err != nil || !found { |
| 156 | continue |
| 157 | } |
| 158 | if apply == "" { |
| 159 | apply = "server" |
| 160 | if signed() { |
| 161 | apply = "local" |
| 162 | } |
| 163 | } |
| 164 | start, end := suggestionRange(cm) |
| 165 | s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA, |
| 166 | Replacement: suggest.Text(lines), Apply: apply} |
| 167 | out[cm.ID] = s |
| 168 | base, _, blob, err := f.read(cm.HeadSHA, cm.Path) |
| 169 | s.Blob = blob |
| 170 | orig, ok := suggest.Range(base, start, end) |
| 171 | if err != nil || !ok { |
| 172 | s.Outdated, s.Reason = true, reasonNoBase |
| 173 | continue |
| 174 | } |
| 175 | s.Original = string(orig) |
| 176 | s.Reason = anchorReason(f, mr.HeadSHA, s) |
| 177 | s.Outdated = s.Reason != "" |
| 178 | } |
| 179 | return out |
| 112 | 180 | } |
| 113 | 181 | |
| 114 | 182 | // anchorReason says why s cannot be applied to the file at head, or "" |
| 115 | 183 | // when the lines it replaces are still what it was made against. |
| 116 | | func anchorReason(dir, head string, s *SuggestionOut) string { |
| 117 | | content, _, err := readAnchored(dir, head, s.Path) |
| 184 | func anchorReason(f *anchoredFiles, head string, s *SuggestionOut) string { |
| 185 | content, _, _, err := f.read(head, s.Path) |
| 118 | 186 | if err != nil { |
| 119 | 187 | return err.Error() |
| 120 | 188 | } |
| @@ -182,7 +250,7 @@ func runMRApplySuggestion(c *Ctx, args []string) int { |
| 182 | 250 | if mr.State != "open" { |
| 183 | 251 | return c.fail(protocol.ExitUsage, "%s!%d is %s", repo.Path(), mr.Number, mr.State) |
| 184 | 252 | } |
| 185 | | s := ThreadSuggestion(c.Store, c.Cfg.Server.Root, repo, mr, cm) |
| 253 | s := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, []store.DiffComment{cm})[cm.ID] |
| 186 | 254 | if s == nil { |
| 187 | 255 | return c.fail(protocol.ExitUsage, "thread %d carries no suggestion", threadID) |
| 188 | 256 | } |
| @@ -232,10 +300,12 @@ func runMRApplySuggestion(c *Ctx, args []string) int { |
| 232 | 300 | if s.Reason == reasonNoBase { |
| 233 | 301 | return c.fail(protocol.ExitUsage, "suggestion in thread %d cannot be applied: %s", threadID, s.Reason) |
| 234 | 302 | } |
| 235 | | if reason := anchorReason(srcDir, tip, s); reason != "" { |
| 303 | files := newAnchoredFiles(srcDir) |
| 304 | defer files.close() |
| 305 | if reason := anchorReason(files, tip, s); reason != "" { |
| 236 | 306 | return c.fail(protocol.ExitUsage, "suggestion in thread %d is outdated: %s", threadID, reason) |
| 237 | 307 | } |
| 238 | | content, mode, err := readAnchored(srcDir, tip, s.Path) |
| 308 | content, mode, _, err := files.read(tip, s.Path) |
| 239 | 309 | if err != nil { |
| 240 | 310 | return c.fail(protocol.ExitUsage, "%v", err) |
| 241 | 311 | } |