summaryrefslogtreecommitdiff
path: root/web/jj.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/jj.go')
-rw-r--r--web/jj.go298
1 files changed, 298 insertions, 0 deletions
diff --git a/web/jj.go b/web/jj.go
new file mode 100644
index 0000000..1dd4a4d
--- /dev/null
+++ b/web/jj.go
@@ -0,0 +1,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
+}