// 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 }