diff options
Diffstat (limited to 'web/jj.go')
| -rw-r--r-- | web/jj.go | 298 |
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 +} |
