From c1a10ccc4cf27f6f6c47159d78ce1598df7c62ad Mon Sep 17 00:00:00 2001 From: Damon Blais Date: Wed, 11 Nov 2026 13:00:31 -0700 Subject: [PATCH 1/3] perf(context): skip the language-server cross-check --- CHANGELOG.md | 6 ++++++ docs/CODES.md | 7 +++---- docs/USAGE.md | 8 +++++--- internal/app/context.go | 20 ++++++++------------ internal/app/context_test.go | 34 +++++++++++++++++++--------------- internal/diff/run.go | 2 +- internal/diff/types.go | 6 ++++++ 7 files changed, 48 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d98acb..a94363e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Notable changes to `rgit`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- `rgit context` no longer cross-checks extents against a language server, so it never dials or spawns one: 552 ms to 234 ms on a 10-file dirty tree in a 3.6k-file repo, and 1-2 s saved where a stdio server (vtsls, marksman) would have started. It no longer emits `W ts-only` or extent-disagreement warnings; `rgit diff` and `rgit commit` still verify. + ## [2.4.1] - 2026-11-09 ### Fixed diff --git a/docs/CODES.md b/docs/CODES.md index 2d7cfc1..b648b7a 100644 --- a/docs/CODES.md +++ b/docs/CODES.md @@ -311,8 +311,8 @@ Always this stream — no aligned default, no `--porcelain`. Why: ```text Bmainorigin/main02 Smerge -Wts-only -Wwarningextent disagreement reported on stderr +Wstash +Wwarningnon-fatal diff diagnostic Fauth.goValidateTokenMOD123 Fconfig.iniUNTRACKED40 Tauth.goboth @@ -327,7 +327,6 @@ XTRUNCATED3 | `B` | `BRANCH`, `UPSTREAM`, `AHEAD`, `BEHIND` | Current branch. Empty `UPSTREAM` and `0`/`0` with no upstream. Absent on unborn; replaced by `H` when detached | | `H` | `SHA` | Detached HEAD; absent on unborn | | `S` | `OP` | `merge`, `cherry-pick`, `revert`, `rebase`, or `bisect` | -| `W` | `ts-only` | Cross-check wanted, no live server. `[ts-only]` also on stderr | | `W` | `stash` | `refs/stash` exists | | `W` | `sparse` | `core.sparseCheckout` is true | | `W` | `warning`, `TEXT` | Non-fatal diff warning without the `[warning]` prefix | @@ -387,7 +386,7 @@ Same order as the text stream, after the 16 KiB cap. Empty columns are | `B` | `branch`, `upstream`, `ahead`, `behind` | | `H` | `sha` | | `S` | `op` | -| `W` | `kind` (`ts-only`, `stash`, `sparse`, or `warning`). `text` only for `warning` | +| `W` | `kind` (`stash`, `sparse`, or `warning`). `text` only for `warning` | | `F` | `file`, `symbol`, `status`, `added`, `deleted` | | `T` | `file`, `where` | | `C` | `hash`, `subject` | diff --git a/docs/USAGE.md b/docs/USAGE.md index 32d31d6..a78f69b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -234,8 +234,7 @@ Neither path, date, nor `--follow`: 129. ```text Bmainorigin/main02 Smerge -Wts-only -Wwarningextent disagreement reported on stderr +Wstash Fauth.goValidateTokenMOD123 Fconfig.iniUNTRACKED40 Tauth.goboth @@ -243,7 +242,10 @@ Tconfig.iniuntracked Ca1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2fix(auth): reject expired tokens ``` -One-call orientation. **The records are fixed** — a command with options +One-call orientation. It never dials a language server, so it emits no +`W ts-only` record and no extent-disagreement warnings (`rgit diff` and +`rgit commit` still cross-check); it costs milliseconds on a dirty tree +instead of 1-2s. **The records are fixed** — a command with options becomes `git status` with extra steps. `--json` is that stream as one object ([`CODES.md`](CODES.md#rgit-context---json)). Any other argument is 129. Grammar: [`CODES.md`](CODES.md#rgit-context). diff --git a/internal/app/context.go b/internal/app/context.go index 2252f51..c596d4a 100644 --- a/internal/app/context.go +++ b/internal/app/context.go @@ -55,10 +55,6 @@ Records, one per line, tab-separated, no header: One active sequencer operation: merge, cherry-pick, revert, rebase, or bisect. - Wts-only - One when at least one file had symbols to cross-check but no live - language server was reached. - Wstash One when the repository has a stash ref. @@ -69,6 +65,10 @@ Records, one per line, tab-separated, no header: One per non-fatal diff diagnostic. TEXT omits the human [warning] prefix that remains on stderr. +context never dials a language server, so it has no Wts-only record +and no extent-disagreement warnings; rgit diff and rgit commit still +cross-check. + FFILESYMBOLSTATUSADDEDDELETED One per "rgit diff --porcelain" row -- identical fields, with this stream's own leading type tag. See docs/CODES.md#output-records for @@ -176,9 +176,11 @@ func runContext(ctx context.Context, dir string, args []string, stdout, stderr i // The default "everything committable" scope -- staged + unstaged vs // HEAD, plus untracked -- is exactly what a bare `rgit diff` already - // reports; no Options fields are set here, guarding against a flag + // reports; no scope fields are set here, guarding against a flag // surface that would let this grow into a second `git status`. - report, err := diffpkg.Run(ctx, repo, root, diffpkg.Options{}) + // SkipCrossCheck keeps the first-turn call off the language servers, + // which cost 1-2s per dirty tree (stdio servers spawn per call). + report, err := diffpkg.Run(ctx, repo, root, diffpkg.Options{SkipCrossCheck: true}) if err != nil { fmt.Fprintf(stderr, "rgit: %v\n", err) return exitcode.GitFailure @@ -195,9 +197,6 @@ func runContext(ctx context.Context, dir string, args []string, stdout, stderr i // Same diagnostics rgit diff itself prints for the identical report, // on stderr rather than folded into the record stream. - if report.TSOnly { - fmt.Fprintln(stderr, tsOnlyNotice) - } for _, w := range report.Warnings { fmt.Fprintf(stderr, "[warning] %s\n", w) } @@ -242,9 +241,6 @@ func assembleContextRecords(report *diffpkg.Report, commits []gitx.CommitSummary if sparseCheckout { records = append(records, "W\tsparse\n") } - if report.TSOnly { - records = append(records, "W\tts-only\n") - } for _, w := range report.Warnings { records = append(records, fmt.Sprintf("W\twarning\t%s\n", w)) } diff --git a/internal/app/context_test.go b/internal/app/context_test.go index 3fe5d00..888f984 100644 --- a/internal/app/context_test.go +++ b/internal/app/context_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/go-quicktest/qt" @@ -123,8 +124,8 @@ func TestRun_ContextEmitsDiffRowsBeforeCommits(t *testing.T) { stdout, stderr, code := runApp(t, "context") qt.Assert(t, qt.Equals(code, exitcode.Success)) - qt.Assert(t, qt.StringContains(stdout, "W\tts-only\n")) - qt.Assert(t, qt.StringContains(stderr, tsOnlyNotice)) + qt.Assert(t, qt.Not(qt.StringContains(stdout, "ts-only"))) + qt.Assert(t, qt.Not(qt.StringContains(stderr, tsOnlyNotice))) qt.Assert(t, qt.StringContains(stdout, "chore: initial")) qt.Assert(t, qt.StringContains(stdout, "F\ta.go\tA\tMOD\t")) qt.Assert(t, qt.StringContains(stdout, "new.txt")) @@ -137,10 +138,11 @@ func TestRun_ContextEmitsDiffRowsBeforeCommits(t *testing.T) { qt.Assert(t, qt.IsTrue(diffIdx < commitIdx)) } -// TestRun_ContextEmitsWarningRecords pins report.Warnings' machine form: -// the warning body follows the W tag without the human [warning] prefix, and -// the same body remains mirrored on stderr. -func TestRun_ContextEmitsWarningRecords(t *testing.T) { +// TestRun_ContextNeverDialsALanguageServer pins that context skips the +// cross-check: a live server that would report an extent disagreement for +// this edit is never connected to, so no ts-only or warning record appears. +// The same edit through rgit diff does reach it. +func TestRun_ContextNeverDialsALanguageServer(t *testing.T) { dir := chdirTempRepo(t) t.Setenv("PATH", isolatedPATHWithGopls(t)) t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) @@ -150,7 +152,9 @@ func TestRun_ContextEmitsWarningRecords(t *testing.T) { {"name":"A","kind":12,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}, {"name":"B","kind":12,"range":{"start":{"line":7,"character":0},"end":{"line":9,"character":1}},"selectionRange":{"start":{"line":7,"character":0},"end":{"line":7,"character":5}}} ]` + var dials atomic.Int32 lsptest.Listen(t, sockPath, func(conn net.Conn) { + dials.Add(1) _ = lsptest.ServeMockLSP(conn, resultJSON, lsptest.MockServerHooks{}) }) t.Setenv("RGIT_LSP_SOCKET", sockPath) @@ -159,15 +163,15 @@ func TestRun_ContextEmitsWarningRecords(t *testing.T) { stdout, stderr, code := runApp(t, "context") qt.Assert(t, qt.Equals(code, exitcode.Success)) + qt.Check(t, qt.Equals(dials.Load(), int32(0))) + qt.Check(t, qt.Not(qt.StringContains(stdout, "W\t"))) + qt.Check(t, qt.Not(qt.StringContains(stderr, "[warning]"))) + qt.Check(t, qt.StringContains(stdout, "F\ta.go\tA\tMOD\t")) - warning := `a.go: resolve: "A":` - qt.Assert(t, qt.StringContains(stdout, "W\twarning\t"+warning)) - qt.Assert(t, qt.StringContains(stderr, "[warning] "+warning)) - warningIdx := strings.Index(stdout, "W\twarning\t") - diffIdx := strings.Index(stdout, "F\t") - qt.Assert(t, qt.IsTrue(warningIdx >= 0)) - qt.Assert(t, qt.IsTrue(diffIdx >= 0)) - qt.Assert(t, qt.IsTrue(warningIdx < diffIdx)) + _, diffErr, code := runApp(t, "diff") + qt.Assert(t, qt.Equals(code, exitcode.Success)) + qt.Check(t, qt.StringContains(diffErr, `a.go: resolve: "A":`)) + qt.Check(t, qt.IsTrue(dials.Load() > 0)) } // TestRun_ContextRecordsAreTabSeparatedWithExpectedFieldCounts pins the @@ -371,7 +375,7 @@ func TestBuildContextStream(t *testing.T) { t.Parallel() records := []string{ "B\tmain\t\t0\t0\n", - "W\tts-only\n", + "W\tstash\n", "F\ta.go\tA\tMOD\t1\t0\n", "F\ta.go\tB\tMOD\t1\t0\n", "F\ta.go\tC\tMOD\t1\t0\n", diff --git a/internal/diff/run.go b/internal/diff/run.go index 03eaff0..b15ad80 100644 --- a/internal/diff/run.go +++ b/internal/diff/run.go @@ -37,7 +37,7 @@ func Run(ctx context.Context, repo *gitx.Repo, root string, opts Options) (*Repo // Only the worktree side can be cross-checked -- a language server has no // view of an arbitrary revision -- so a revision-to-revision diff skips it. var sess *lsp.Session - if scope.New.kind == sideWorktree { + if scope.New.kind == sideWorktree && !opts.SkipCrossCheck { sess = lsp.NewSession() defer sess.Close() } diff --git a/internal/diff/types.go b/internal/diff/types.go index 0afb7b5..2a067c0 100644 --- a/internal/diff/types.go +++ b/internal/diff/types.go @@ -170,6 +170,12 @@ type Options struct { // false changes nothing about the rest of Run's behavior. Patch bool + // SkipCrossCheck leaves the language-server cross-check out: no server + // is dialled, so the report carries no TSOnly flag and no cross-check + // mismatch warnings. Rows are unchanged, since they come from the + // tree-sitter walk alone. + SkipCrossCheck bool + // RevPaths is the two-blob "A:f.go B:f.go" scope: exactly two // cli.RevPath values naming the identical path at two revisions // (BucketClassified's own pairing rule, classify.go), or empty for From 2de9ded75734a6901bcf4a12b7ab945b3c5c129f Mon Sep 17 00:00:00 2001 From: Damon Blais Date: Wed, 11 Nov 2026 13:01:02 -0700 Subject: [PATCH 2/3] perf(lsp): keep the gopls daemon for two idle hours --- CHANGELOG.md | 1 + internal/lsp/servers.go | 9 +++++++-- internal/lsp/servers_test.go | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94363e..62a7c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Notable changes to `rgit`. The format follows ### Changed - `rgit context` no longer cross-checks extents against a language server, so it never dials or spawns one: 552 ms to 234 ms on a 10-file dirty tree in a 3.6k-file repo, and 1-2 s saved where a stdio server (vtsls, marksman) would have started. It no longer emits `W ts-only` or extent-disagreement warnings; `rgit diff` and `rgit commit` still verify. +- The gopls daemon rgit spawns now idles for 2 hours (was 10 minutes), so a working session pays the module load once instead of after every pause. ## [2.4.1] - 2026-11-09 diff --git a/internal/lsp/servers.go b/internal/lsp/servers.go index 98b2ae2..2e09603 100644 --- a/internal/lsp/servers.go +++ b/internal/lsp/servers.go @@ -54,6 +54,9 @@ var vtslsSpec = serverSpec{ // servers maps a resolve.Language's Name() to the server that cross-checks // it. "tsx" shares vtsls with "typescript" — one TypeScript server handles // both grammars; only the didOpen languageId differs (client.go). +// goplsIdleTimeout is how long an unused gopls daemon lives. +const goplsIdleTimeout = "2h" + var servers = map[string]serverSpec{ "go": { name: "gopls", @@ -62,8 +65,10 @@ var servers = map[string]serverSpec{ daemonArgs: func(sockPath string) []string { // listen.timeout shuts the daemon down after it sits idle with // no connected client, so a spawned-and-forgotten gopls does - // not outlive every rgit invocation that will ever ask for it. - return []string{"-listen=unix;" + sockPath, "-listen.timeout=10m"} + // not outlive a working session. Loading a large module costs + // a minute of CPU, so the window must outlast the gaps between + // rgit calls in a session or every call after one pays it again. + return []string{"-listen=unix;" + sockPath, "-listen.timeout=" + goplsIdleTimeout} }, }, // "typescript" and "tsx" both dial vtsls -- one server, two grammars diff --git a/internal/lsp/servers_test.go b/internal/lsp/servers_test.go index f5ad207..cce4d28 100644 --- a/internal/lsp/servers_test.go +++ b/internal/lsp/servers_test.go @@ -163,3 +163,11 @@ func TestServers(t *testing.T) { } } } + +func TestGoplsDaemonIdleTimeoutOutlastsASession(t *testing.T) { + args := servers["go"].daemonArgs("/run/x/rgit-gopls.sock") + want := []string{"-listen=unix;/run/x/rgit-gopls.sock", "-listen.timeout=2h"} + if !slices.Equal(args, want) { + t.Errorf("gopls daemon args = %v, want %v", args, want) + } +} From 026f983e81c0629a91c60af1e613b71b6f1145a3 Mon Sep 17 00:00:00 2001 From: Damon Blais Date: Wed, 11 Nov 2026 13:01:14 -0700 Subject: [PATCH 3/3] docs(security): reporting, disclosure and response timelines --- SECURITY.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 28dbc88..0ee7c59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,10 +7,27 @@ fix ships in the next tag rather than being backported. ## Reporting a vulnerability -Use GitHub's **private vulnerability reporting** on this repository -(Security → Report a vulnerability). It stays private until a fix ships. +Use GitHub's private vulnerability reporting on this repository: , or the **Security** tab, then **Report a vulnerability**. The report stays private between you and us until a fix ships. -Please do not open a public issue for a suspected vulnerability first. +If private reporting is unavailable, email [oss@rethunk.tech](mailto:oss@rethunk.tech) with **[SECURITY]** in the subject. + +Please do not open a public issue, pull request or discussion for a suspected vulnerability. + +A useful report has the affected repository and version (or commit), steps to reproduce, what an attacker gets and under which conditions, and whether anyone else knows. + +## What counts as a vulnerability + +A vulnerability is a flaw that lets someone cross a boundary they should not: run code, read or change data they have no right to, escape a sandbox or path restriction, or leak a credential. A bug is anything else that misbehaves, such as a crash on your own input, wrong output or a slow path; open an ordinary issue for those. If you are unsure which one you have, report it privately and we will say. + +## Coordinated disclosure + +We handle the report inside the private advisory, so you see each status change and can comment on the fix. The advisory goes public when the fix ships, or 90 days after your report, whichever comes first. If a fix is close, either of us can ask for more time in the thread. You are credited in the advisory unless you would rather not be. + +## Response timelines + +- We acknowledge your report within 3 business days. +- We give you an assessment (accepted, needs more information or declined, with reasons) within 10 business days. +- We aim to ship a fix within 90 days of the report. ## What is in scope @@ -24,13 +41,13 @@ are ones where it writes something the caller did not name: - Anything that causes `rgit` to run a command the caller did not ask for, beyond the `git` invocations it documents. -## What is not +## What is out of scope - **Hooks running arbitrary code.** `rgit` runs git's hooks exactly as `git commit` does, and deliberately does not police them ([`docs/USAGE.md`](docs/USAGE.md#behaviour-inherited-from-git)). A malicious hook in a repository you have already checked out is outside - this boundary — as it is for `git` itself. + this boundary, as it is for `git` itself. - **Language servers and the tree-sitter CLI.** Both are optional external programs you install; `rgit` shells out to them and trusts them the way any editor does. That trust does not extend to the path `rgit` reaches @@ -43,7 +60,11 @@ are ones where it writes something the caller did not name: `privateSocketDir`). Windows has no UID to scope by (`os.Getuid` returns `-1`, naming the directory `rgit--1`) and needs no ownership check: its per-user temp directory is already ACL-restricted by the platform. A - caller-supplied `$RGIT_LSP_SOCKET` is exempt — it is the caller's path to + caller-supplied `$RGIT_LSP_SOCKET` is exempt: it is the caller's path to manage, not one `rgit` vouches for. -- **A wrong extent that stages too little.** That is a correctness bug — +- **A wrong extent that stages too little.** That is a correctness bug; please file it as an ordinary issue. + +## Safe harbour + +We will not pursue legal action against good-faith research that avoids privacy violations, data destruction and disruption of services, stops at a proof of concept, and keeps the details private until the advisory is published. Rethunk.Tech, LLC maintains this repository.