summaryrefslogtreecommitdiff
path: root/web/jj.go
blob: 1dd4a4dc298ebd935ea18efd78d149c37b27f5be (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
// jj.go — every read of the repository goes through here.
//
// These helpers never modify project history or its working copy
// (--ignore-working-copy means they never even snapshot it). Sidecar mutation
// is isolated in the shared sidecar package.
package web

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"time"
)

// Field separator inside a templated line, record marker at its start. Both
// are ASCII control characters that cannot appear in ids, names or a
// description's first line, so parsing stays trivial even under a graph.
const (
	jjRec = "\x1e" // as jj prints it
	jjSep = "\x1f"
	// as it is written inside a jj template string literal
	jjRecEsc = `\x1e`
	jjSepEsc = `\x1f`
)

const jjTimeout = 30 * time.Second

// jjRun executes jj read-only in repo. stderr comes back inside the error so
// the page can show what jj complained about.
func jjRun(repo string, args ...string) ([]byte, error) {
	ctx, cancel := context.WithTimeout(context.Background(), jjTimeout)
	defer cancel()
	cmd := exec.CommandContext(ctx, "jj", append([]string{"--ignore-working-copy"}, args...)...)
	cmd.Dir = repo
	var errb strings.Builder
	cmd.Stderr = &errb
	out, err := cmd.Output()
	if err != nil {
		msg := strings.TrimSpace(errb.String())
		if msg == "" {
			msg = err.Error()
		}
		return out, fmt.Errorf("jj %s: %s", strings.Join(args, " "), msg)
	}
	return out, nil
}

// mustJJ is the static-build path: a jj failure there is fatal.
func mustJJ(repo string, args ...string) []byte {
	out, err := jjRun(repo, args...)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		fatal("repository read failed")
	}
	return out
}

// checkRevset rejects input that jj would read as a flag; revsets themselves
// are a query language, not a shell, and are passed as one argv entry.
func checkRevset(r string) error {
	r = strings.TrimSpace(r)
	if strings.HasPrefix(r, "-") {
		return fmt.Errorf("revset must not start with %q", "-")
	}
	if len(r) > 512 {
		return fmt.Errorf("revset too long")
	}
	return nil
}

func resolveChange(repo, rev string) (string, error) {
	change, _, err := resolveRevision(repo, rev)
	return change, err
}

func resolveRevision(repo, rev string) (change, commit string, err error) {
	if rev == "" {
		rev = "@"
	}
	if err := checkRevset(rev); err != nil {
		return "", "", err
	}
	out, err := jjRun(repo, "log", "-r", "exactly(("+rev+"), 1)", "--no-graph",
		"-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"")
	if err != nil {
		return "", "", err
	}
	ids := strings.Fields(string(out))
	if len(ids) != 2 {
		return "", "", fmt.Errorf("revset %q did not resolve to one change and commit", rev)
	}
	return ids[0], ids[1], nil
}

// changeBrief resolves a revset to the one change it names, with everything a
// page says about it. One call answers both "which change" and "which commit
// is under it right now" — the second is what content is cached against, since
// a change id is a pointer and the commit beneath it is rewritten on every
// edit.
func changeBrief(repo, rev string) (Row, error) {
	rows, err := LogRows(repo, rev, "", 1)
	if err != nil {
		return Row{}, err
	}
	for _, r := range rows {
		if !r.Blank() {
			return r, nil
		}
	}
	return Row{}, fmt.Errorf("revset %q matches no change", rev)
}

func fileList(repo, rev string) ([]string, error) {
	out, err := jjRun(repo, "file", "list", "-r", rev)
	if err != nil {
		return nil, err
	}
	return strings.Split(strings.TrimRight(string(out), "\n"), "\n"), nil
}

func fileShow(repo, rev, path string) ([]byte, error) {
	return jjRun(repo, "file", "show", "-r", rev, "--", path)
}

// ── log / op log rows ─────────────────────────────────────────────────────

// A Row is one line of jj output: the graph art it was drawn with, plus the
// fields the inline template produced. Graph-only lines (│, ~) carry Graph
// alone and render as decoration.
type Row struct {
	Graph               string
	ID, Short           string
	Commit, CommitShort string
	Author, When, End   string
	Refs, Flags, Desc   string
	Op                  bool
}

func (r Row) Blank() bool { return r.ID == "" }

// tmplFields builds `"\x1e" ++ f1 ++ "\x1f" ++ f2 ... ++ "\n"`: one line per
// row, record-marked so the graph art in front of it is separable.
func tmplFields(fields ...string) string {
	return `"` + jjRecEsc + `" ++ ` + strings.Join(fields, ` ++ "`+jjSepEsc+`" ++ `) + ` ++ "\n"`
}

var (
	commitTmpl = tmplFields(
		"change_id",
		"change_id.short(8)",
		"commit_id",
		"commit_id.short(8)",
		"author.email()",
		`committer.timestamp().format("%Y-%m-%d %H:%M")`,
		`bookmarks.join(" ")`,
		`separate(" ", if(current_working_copy, "working copy"), if(empty, "empty"), if(conflict, "conflict"), if(divergent, "divergent"))`,
		"description.first_line()",
	)
	opTmpl = tmplFields(
		"id",
		"id.short(12)",
		`""`,
		`""`,
		"user",
		`time.start().format("%Y-%m-%d %H:%M:%S")`,
		`""`,
		`time.end().format("%H:%M:%S")`,
		"description.first_line()",
	)
)

func parseRows(out []byte, op bool) []Row {
	var rows []Row
	for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
		i := strings.Index(line, jjRec)
		if i < 0 {
			if strings.TrimSpace(line) != "" {
				rows = append(rows, Row{Graph: line, Op: op})
			}
			continue
		}
		f := strings.Split(line[i+len(jjRec):], jjSep)
		for len(f) < 9 {
			f = append(f, "")
		}
		r := Row{Graph: line[:i], ID: f[0], Short: f[1], Commit: f[2], CommitShort: f[3],
			Author: f[4], When: f[5], Refs: f[6], Flags: f[7], Desc: f[8], Op: op}
		if op {
			r.End, r.Flags = r.Flags, ""
		}
		rows = append(rows, r)
	}
	return rows
}

// LogRows runs `jj log -r REVSET` with the inline template. atOp, when set,
// runs the query as of that operation — the debugging view.
func LogRows(repo, revset, atOp string, limit int) ([]Row, error) {
	if err := checkRevset(revset); err != nil {
		return nil, err
	}
	args := []string{"log", "-T", commitTmpl}
	if strings.TrimSpace(revset) != "" {
		args = append(args, "-r", revset)
	}
	if atOp != "" {
		if err := checkRevset(atOp); err != nil {
			return nil, err
		}
		args = append(args, "--at-operation", atOp)
	}
	if limit > 0 {
		args = append(args, "-n", fmt.Sprint(limit))
	}
	out, err := jjRun(repo, args...)
	if err != nil {
		return nil, err
	}
	return parseRows(out, false), nil
}

func OpRows(repo string, limit int) ([]Row, error) {
	args := []string{"op", "log", "-T", opTmpl}
	if limit > 0 {
		args = append(args, "-n", fmt.Sprint(limit))
	}
	out, err := jjRun(repo, args...)
	if err != nil {
		return nil, err
	}
	return parseRows(out, true), nil
}

// ── one change ────────────────────────────────────────────────────────────

type ChangeFile struct {
	Status, Path string
}

type Change struct {
	Row     Row
	Desc    string
	Files   []ChangeFile
	Stat    string
	Parents []Row
}

var statusName = map[string]string{"A": "added", "M": "modified", "D": "deleted", "R": "renamed", "C": "copied"}

func LoadChange(repo, rev string) (*Change, error) {
	rows, err := LogRows(repo, rev, "", 1)
	if err != nil {
		return nil, err
	}
	var row Row
	for _, r := range rows {
		if !r.Blank() {
			row = r
			break
		}
	}
	if row.Blank() {
		return nil, fmt.Errorf("no change matches %q", rev)
	}
	c := &Change{Row: row}
	// full description separately: it is multi-line, so it cannot ride along
	// in the one-line-per-row template
	if out, err := jjRun(repo, "log", "-r", row.ID, "--no-graph", "-n", "1", "-T", "description"); err == nil {
		c.Desc = strings.TrimRight(string(out), "\n")
	}
	if out, err := jjRun(repo, "diff", "-r", row.ID, "--summary"); err == nil {
		for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
			if len(line) > 2 {
				st := statusName[line[:1]]
				if st == "" {
					st = line[:1]
				}
				c.Files = append(c.Files, ChangeFile{Status: st, Path: line[2:]})
			}
		}
	}
	if out, err := jjRun(repo, "diff", "-r", row.ID, "--stat"); err == nil {
		lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
		if n := len(lines); n > 0 {
			c.Stat = strings.TrimSpace(lines[n-1])
		}
	}
	if rows, err := LogRows(repo, "parents("+row.ID+")", "", 8); err == nil {
		for _, r := range rows {
			if !r.Blank() {
				c.Parents = append(c.Parents, r)
			}
		}
	}
	return c, nil
}