| @@ -52,23 +52,78 @@ const ( |
| 52 | reasonNoBase = "the commit it was made against is no longer available" |
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. |
55 | // anchoredFiles reads the files suggestions anchor in, for one page or |
| 56 | func readAnchored(dir, commit, path string) ([]byte, string, error) { |
56 | // listing: one ls-tree per (commit, path), and every blob through one |
| 57 | e, ok := gitutil.StatPath(dir, commit, path) |
57 | // cat-file --batch process started on first use, so a merge request with |
| 58 | if !ok { |
58 | // many suggestions on a file costs no more processes than one with a |
| 59 | return nil, "", fmt.Errorf("%s", reasonGone) |
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 | if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") { |
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 | if e.Size > maxSuggestionBytes { |
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 | if err != nil { |
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 | // suggestionRange is a thread's first and last line. |
129 | // suggestionRange is a thread's first and last line. |
| @@ -76,45 +131,58 @@ func suggestionRange(cm store.DiffComment) (int, int) { |
| 76 | return int(firstNonZero(cm.StartLine, cm.Line)), int(cm.Line) |
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 |
134 | // Suggestions are the suggestions the thread roots among comments carry, |
| 80 | // against the merge request's head, or nil when it carries none. The |
135 | // by thread id, each checked against the merge request's head. The |
| 81 | // original lines are read from the commit the comment was made on, in |
136 | // original lines are read from the commit the comment was made on, in |
| 82 | // the target repository, which holds every head the merge request has |
137 | // the target repository, which holds every head the merge request has |
| 83 | // had until gc prunes an abandoned one. |
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 { |
139 | func Suggestions(st *store.Store, root string, repo store.Repo, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut { |
| 85 | if cm.ReplyTo != 0 || cm.Side != "new" { |
140 | f := newAnchoredFiles(RepoDir(root, repo.OwnerName, repo.Name)) |
| 86 | return nil |
141 | defer f.close() |
| 87 | } |
142 | return suggestionsWith(f, func() bool { return signedOnly(st, repo, mr) }, mr, comments) |
| 88 | lines, found, err := suggest.Parse(cm.Body) |
143 | } |
| 89 | if err != nil || !found { |
144 | |
| 90 | return nil |
145 | // suggestionsWith is Suggestions reading through f. signed is asked |
| 91 | } |
146 | // once, and only when there is a suggestion. |
| 92 | start, end := suggestionRange(cm) |
147 | func suggestionsWith(f *anchoredFiles, signed func() bool, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut { |
| 93 | s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA, |
148 | out := map[int64]*SuggestionOut{} |
| 94 | Replacement: suggest.Text(lines), Apply: "server"} |
149 | apply := "" |
| 95 | if signedOnly(st, repo, mr) { |
150 | for _, cm := range comments { |
| 96 | s.Apply = "local" |
151 | if cm.ReplyTo != 0 || cm.Side != "new" { |
| 97 | } |
152 | continue |
| 98 | dir := RepoDir(root, repo.OwnerName, repo.Name) |
153 | } |
| 99 | if e, ok := gitutil.StatPath(dir, cm.HeadSHA, cm.Path); ok { |
154 | lines, found, err := suggest.Parse(cm.Body) |
| 100 | s.Blob = e.SHA |
155 | if err != nil || !found { |
| 101 | } |
156 | continue |
| 102 | base, _, err := readAnchored(dir, cm.HeadSHA, cm.Path) |
157 | } |
| 103 | orig, ok := suggest.Range(base, start, end) |
158 | if apply == "" { |
| 104 | if err != nil || !ok { |
159 | apply = "server" |
| 105 | s.Outdated, s.Reason = true, reasonNoBase |
160 | if signed() { |
| 106 | return s |
161 | apply = "local" |
| 107 | } |
162 | } |
| 108 | s.Original = string(orig) |
163 | } |
| 109 | s.Reason = anchorReason(dir, mr.HeadSHA, s) |
164 | start, end := suggestionRange(cm) |
| 110 | s.Outdated = s.Reason != "" |
165 | s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA, |
| 111 | return s |
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 | // anchorReason says why s cannot be applied to the file at head, or "" |
182 | // anchorReason says why s cannot be applied to the file at head, or "" |
| 115 | // when the lines it replaces are still what it was made against. |
183 | // when the lines it replaces are still what it was made against. |
| 116 | func anchorReason(dir, head string, s *SuggestionOut) string { |
184 | func anchorReason(f *anchoredFiles, head string, s *SuggestionOut) string { |
| 117 | content, _, err := readAnchored(dir, head, s.Path) |
185 | content, _, _, err := f.read(head, s.Path) |
| 118 | if err != nil { |
186 | if err != nil { |
| 119 | return err.Error() |
187 | return err.Error() |
| 120 | } |
188 | } |
| @@ -182,7 +250,7 @@ func runMRApplySuggestion(c *Ctx, args []string) int { |
| 182 | if mr.State != "open" { |
250 | if mr.State != "open" { |
| 183 | return c.fail(protocol.ExitUsage, "%s!%d is %s", repo.Path(), mr.Number, mr.State) |
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 | if s == nil { |
254 | if s == nil { |
| 187 | return c.fail(protocol.ExitUsage, "thread %d carries no suggestion", threadID) |
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 | if s.Reason == reasonNoBase { |
300 | if s.Reason == reasonNoBase { |
| 233 | return c.fail(protocol.ExitUsage, "suggestion in thread %d cannot be applied: %s", threadID, s.Reason) |
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 | return c.fail(protocol.ExitUsage, "suggestion in thread %d is outdated: %s", threadID, reason) |
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 | if err != nil { |
309 | if err != nil { |
| 240 | return c.fail(protocol.ExitUsage, "%v", err) |
310 | return c.fail(protocol.ExitUsage, "%v", err) |
| 241 | } |
311 | } |