Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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.
- 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

### Fixed
Expand Down
35 changes: 28 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://github.com/Rethunk-Tech/rethunk-git-cli/security/advisories/new>, 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

Expand All @@ -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
Expand All @@ -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.
7 changes: 3 additions & 4 deletions docs/CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ Always this stream — no aligned default, no `--porcelain`. Why:
```text
B<TAB>main<TAB>origin/main<TAB>0<TAB>2
S<TAB>merge
W<TAB>ts-only
W<TAB>warning<TAB>extent disagreement reported on stderr
W<TAB>stash
W<TAB>warning<TAB>non-fatal diff diagnostic
F<TAB>auth.go<TAB>ValidateToken<TAB>MOD<TAB>12<TAB>3
F<TAB>config.ini<TAB><TAB>UNTRACKED<TAB>4<TAB>0
T<TAB>auth.go<TAB>both
Expand All @@ -327,7 +327,6 @@ X<TAB>TRUNCATED<TAB>3
| `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 |
Expand Down Expand Up @@ -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` |
Expand Down
8 changes: 5 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,18 @@ Neither path, date, nor `--follow`: 129.
```text
B<TAB>main<TAB>origin/main<TAB>0<TAB>2
S<TAB>merge
W<TAB>ts-only
W<TAB>warning<TAB>extent disagreement reported on stderr
W<TAB>stash
F<TAB>auth.go<TAB>ValidateToken<TAB>MOD<TAB>12<TAB>3
F<TAB>config.ini<TAB><TAB>UNTRACKED<TAB>4<TAB>0
T<TAB>auth.go<TAB>both
T<TAB>config.ini<TAB>untracked
C<TAB>a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2<TAB>fix(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).
Expand Down
20 changes: 8 additions & 12 deletions internal/app/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ Records, one per line, tab-separated, no header:
One active sequencer operation: merge, cherry-pick, revert, rebase, or
bisect.

W<TAB>ts-only
One when at least one file had symbols to cross-check but no live
language server was reached.

W<TAB>stash
One when the repository has a stash ref.

Expand All @@ -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 W<TAB>ts-only record
and no extent-disagreement warnings; rgit diff and rgit commit still
cross-check.

F<TAB>FILE<TAB>SYMBOL<TAB>STATUS<TAB>ADDED<TAB>DELETED
One per "rgit diff --porcelain" row -- identical fields, with this
stream's own leading type tag. See docs/CODES.md#output-records for
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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))
}
Expand Down
34 changes: 19 additions & 15 deletions internal/app/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/go-quicktest/qt"
Expand Down Expand Up @@ -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"))
Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/diff/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
6 changes: 6 additions & 0 deletions internal/diff/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions internal/lsp/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/lsp/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading