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
27 changes: 27 additions & 0 deletions .github/workflows/changelog-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ jobs:
with:
fetch-depth: 0

# release.yml tags but cannot rewrite the changelog, since main takes no push, so a
# shipped release sits under [Unreleased] until someone renames it and the next entry
# files itself alongside. runs on every PR: an exempt one merging first would let a
# second release land and leave two releases' entries in one section
- name: Close off the last release
run: |
LATEST=$(git tag --sort=-v:refname | head -1)
[ -n "$LATEST" ] || exit 0

unreleased=$(git show HEAD:CHANGELOG.md |
awk '/^## \[Unreleased\]/ { inside = 1; next } /^## / { inside = 0 } inside' |
grep -v '^[[:space:]]*$' || true)
if [ -z "$unreleased" ]; then
echo "[Unreleased] is empty, nothing to close off"
exit 0
fi

NEWEST=$(git show HEAD:CHANGELOG.md |
grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' | tr -d '#[] ')
if [ "$NEWEST" = "${LATEST#v}" ]; then
echo "CHANGELOG.md is closed off at $LATEST"
exit 0
fi

echo "::error::CHANGELOG.md: $LATEST is released but the newest heading is [$NEWEST]. Retitle '## [Unreleased]' as version ${LATEST#v}, dated $(git log -1 --format=%as "$LATEST"), and open an empty '## [Unreleased]' above it"
exit 1

- name: Require an Unreleased entry
env:
TITLE: ${{ github.event.pull_request.title }}
Expand Down
59 changes: 48 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ jobs:
- name: Vet
run: make go-vet

- name: Lint
uses: golangci/golangci-lint-action@v8
# the Makefile fetches a version-pinned golangci-lint into .tools, so ci and a
# local `make check` run the same binary; cache it on the Makefile that pins it
- uses: actions/cache@v4
with:
version: latest
path: .tools/golangci-lint-*
key: golangci-${{ runner.os }}-${{ hashFiles('Makefile') }}

- name: Lint
run: make go-lint

- name: Test
run: make go-test
Expand All @@ -43,33 +48,60 @@ jobs:
- name: Build demo fixture sidecar
run: make demo-build

gql-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

# the only check that can see a query github would refuse: every other go test fakes
# at the RoundTripper boundary, where canned json answers one. GITHUB_TOKEN only has
# to authenticate, since no document gets past variable coercion
- name: Validate GraphQL documents
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make gql-validate

stylua:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: JohnnyMorganz/stylua-action@v4
- uses: actions/cache@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check lua plugin test
path: .tools/stylua-*
key: stylua-${{ runner.os }}-${{ hashFiles('Makefile') }}

- name: Format check
run: make lua-fmt-check

luacheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: lunarmodules/luacheck@v1
# luacheck is a rock and 1.2.0 refuses to load on lua 5.5, so the interpreter is
# half the pin: 5.1, the version both test suites already run on. the tree isn't
# cached, since the luarocks wrapper bakes in an interpreter path
- uses: leafo/gh-actions-lua@v13
with:
args: lua
luaVersion: "5.1"

- uses: leafo/gh-actions-luarocks@v6

- name: Luacheck
run: make lua-luacheck

lua-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# the Makefile fetches a version-pinned lua_ls into .tools; cache it on
# the pin (keying on the Makefile line that declares it)
# the Makefile fetches a version-pinned lua_ls into .tools; cache it on the
# Makefile that pins it
- uses: actions/cache@v4
with:
path: .tools/lua-language-server-*
Expand All @@ -94,6 +126,11 @@ jobs:
neovim: true
version: ${{ matrix.neovim-version }}

# sidecar_spec drives the real binary, which `make lua-test-nvim` builds
- uses: actions/setup-go@v5
with:
go-version-file: go.mod

# lua 5.1 + luarocks so busted/nlua match the Makefile's headless suite
- uses: leafo/gh-actions-lua@v13
with:
Expand Down
12 changes: 12 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: "2"

# the v2 defaults, named rather than inherited. the file also anchors config resolution
# here: golangci-lint otherwise walks up and adopts a parent directory's config
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.1.30] — 2026-08-12

### Added

- differ warns once when `termguicolors` is off, which is what leaves the diff rendering uncoloured
Expand Down
95 changes: 74 additions & 21 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,27 @@ LUALS_VERSION := 3.18.2
LUALS_DIR := .tools/lua-language-server-$(LUALS_VERSION)
LUALS_BIN := $(LUALS_DIR)/bin/lua-language-server

# pinned panvimdoc: doc/differ.txt is generated from README.md, so the vimdoc and
# the readme can't drift. fetched into .tools rather than taken from PATH. pandoc is
# pinned and version-checked too, since ci only verifies the committed file: a
# different pandoc would fail that check with a diff you didn't write
# fetched into .tools rather than taken from PATH so they can't drift
PANVIMDOC_VERSION := v4.0.1
PANVIMDOC_DIR := .tools/panvimdoc-$(PANVIMDOC_VERSION:v%=%)
PANVIMDOC_BIN := $(PANVIMDOC_DIR)/panvimdoc.sh
PANDOC_VERSION := 3.10.1

GOLANGCI_VERSION := 2.12.2
GOLANGCI_DIR := .tools/golangci-lint-$(GOLANGCI_VERSION)
GOLANGCI_BIN := $(GOLANGCI_DIR)/golangci-lint

STYLUA_VERSION := 2.5.2
STYLUA_DIR := .tools/stylua-$(STYLUA_VERSION)
STYLUA_BIN := $(STYLUA_DIR)/stylua

LUACHECK_VERSION := 1.2.0
LUACHECK_DIR := .tools/luacheck-$(LUACHECK_VERSION)
LUACHECK_BIN := $(LUACHECK_DIR)/bin/luacheck

.PHONY: help \
lua-test lua-test-unit lua-test-nvim lua-lint lua-typecheck lua-fmt lua-fmt-check \
go-build demo-build go-test go-vet go-lint go-fmt go-fmt-check \
lua-test lua-test-unit lua-test-nvim lua-lint lua-luacheck lua-typecheck lua-fmt lua-fmt-check \
go-build demo-build go-test go-vet go-lint go-fmt go-fmt-check gql-validate \
test lint fmt fmt-check check clean \
vimdoc \
demo demo-fixtures
Expand All @@ -54,22 +63,26 @@ lua-test-unit: ## Run pure-Lua unit tests only (fast, no Neovim runtime)
@$(INFO) "Running unit tests"
@busted --run unit

lua-test-nvim: ## Run headless-nvim tests (needs nlua on PATH)
# go-build first: sidecar_spec drives the real binary, and skipped itself when it was absent
lua-test-nvim: go-build ## Run headless-nvim tests (needs nlua on PATH)
@$(INFO) "Running headless-nvim tests"
@eval $$(luarocks --lua-version=5.1 path) && busted --lua=nlua --run nvim

lua-lint: ## Luacheck + stylua --check on Lua sources
@$(INFO) "Linting Lua"
@luacheck lua
@stylua --check lua plugin test
@$(OK) "Lua lint clean"
lua-lint: lua-luacheck lua-fmt-check ## Luacheck + stylua --check on Lua sources

lua-fmt: ## Format Lua sources with stylua
@stylua lua plugin test
lua-luacheck: $(LUACHECK_BIN) ## Luacheck over lua/
@$(INFO) "Linting Lua (luacheck $(LUACHECK_VERSION))"
@$(LUACHECK_BIN) lua
@$(OK) "Luacheck clean"

lua-fmt: $(STYLUA_BIN) ## Format Lua sources with stylua
@$(STYLUA_BIN) lua plugin test
@$(OK) "Lua formatted"

lua-fmt-check: ## Verify Lua formatting without writing
@stylua --check lua plugin test
lua-fmt-check: $(STYLUA_BIN) ## Verify Lua formatting without writing
@$(INFO) "Checking Lua formatting (stylua $(STYLUA_VERSION))"
@$(STYLUA_BIN) --check lua plugin test
@$(OK) "Lua formatting clean"

# checks lua/ only; test specs deliberately pass invalid inputs. lua_ls config
# discovery is path-sensitive, so point at the repo-root .luarc.json explicitly
Expand All @@ -89,6 +102,29 @@ $(LUALS_BIN):
url="https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-$$os-$$arch.tar.gz"; \
curl -fsSL "$$url" | tar -xz -C $(LUALS_DIR) && $(OK) "Installed lua_ls $(LUALS_VERSION)"

$(STYLUA_BIN):
@$(INFO) "Fetching stylua $(STYLUA_VERSION)"
@mkdir -p $(STYLUA_DIR)
@os=$$(uname -s | tr 'A-Z' 'a-z'); \
arch=$$(uname -m); \
case "$$os" in darwin) os=macos;; linux) ;; *) printf "$(RED)unsupported OS: $$os$(NC)\n"; exit 1;; esac; \
case "$$arch" in x86_64) arch=x86_64;; aarch64|arm64) arch=aarch64;; esac; \
url="https://github.com/JohnnyMorganz/StyLua/releases/download/v$(STYLUA_VERSION)/stylua-$$os-$$arch.zip"; \
tmp=$$(mktemp -d); \
curl -fsSL -o "$$tmp/stylua.zip" "$$url" && unzip -qj -d $(STYLUA_DIR) "$$tmp/stylua.zip"; \
rm -rf "$$tmp"; \
test -x $(STYLUA_BIN) || { printf "$(RED)stylua missing from the archive$(NC)\n"; exit 1; }
@$(OK) "Installed stylua $(STYLUA_VERSION)"

# luacheck ships as a rock, so it pins into its own luarocks tree rather than an archive.
# the interpreter is half the pin: 1.2.0 does not run on lua 5.5, and 5.1 is what the
# rest of the suite uses
$(LUACHECK_BIN):
@$(INFO) "Fetching luacheck $(LUACHECK_VERSION)"
@luarocks install --tree $(LUACHECK_DIR) --lua-version 5.1 luacheck $(LUACHECK_VERSION) >/dev/null
@test -x $(LUACHECK_BIN) || { printf "$(RED)luacheck missing from $(LUACHECK_DIR)$(NC)\n"; exit 1; }
@$(OK) "Installed luacheck $(LUACHECK_VERSION)"

# ──────────────────────────────────────────────────────────────────────────────
##@ Go sidecar
# ──────────────────────────────────────────────────────────────────────────────
Expand All @@ -104,17 +140,34 @@ demo-build: ## Type-check the demo fixture sidecar (.demo is invisible to ./...)
@go build -o /dev/null ./.demo/fake-sidecar
@$(OK) "Demo fixture sidecar builds"

go-test: ## Run Go tests
@go test ./...
go-test: ## Run Go tests with the race detector
@go test -race ./...

go-vet: ## Run go vet over the module
@go vet ./...

go-lint: ## Run golangci-lint over the module
@$(INFO) "Linting Go"
@golangci-lint run ./...
# out of `check`: it needs network and an authenticated gh
gql-validate: ## Post every graphql document to github's live schema
@$(INFO) "Validating GraphQL documents against the live schema"
@DIFFER_GRAPHQL_VALIDATE=1 go test -count=1 -run TestDocumentsValidateAgainstLiveSchema ./internal/github/

go-lint: $(GOLANGCI_BIN) ## Run golangci-lint over the module
@$(INFO) "Linting Go (golangci-lint $(GOLANGCI_VERSION))"
@$(GOLANGCI_BIN) run ./...
@$(OK) "Go lint clean"

$(GOLANGCI_BIN):
@$(INFO) "Fetching golangci-lint $(GOLANGCI_VERSION)"
@mkdir -p $(GOLANGCI_DIR)
@os=$$(uname -s | tr 'A-Z' 'a-z'); \
arch=$$(uname -m); \
case "$$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; esac; \
case "$$os" in darwin|linux) ;; *) printf "$(RED)unsupported OS: $$os$(NC)\n"; exit 1;; esac; \
url="https://github.com/golangci/golangci-lint/releases/download/v$(GOLANGCI_VERSION)/golangci-lint-$(GOLANGCI_VERSION)-$$os-$$arch.tar.gz"; \
curl -fsSL "$$url" | tar -xz --strip-components=1 -C $(GOLANGCI_DIR); \
test -x $(GOLANGCI_BIN) || { printf "$(RED)golangci-lint missing from the archive$(NC)\n"; exit 1; }
@$(OK) "Installed golangci-lint $(GOLANGCI_VERSION)"

go-fmt: ## Format Go sources with gofmt
@gofmt -w cmd internal .demo
@$(OK) "Go formatted"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
[![macOS](https://img.shields.io/badge/macOS-supported-6e7681?style=flat&logo=apple&logoColor=white)]()
[![Linux](https://img.shields.io/badge/Linux-supported-6e7681?style=flat&logo=linux&logoColor=white)]()

[Features](#features) · [Installation](#installation) · [Configuration](#configuration) · [Usage](#usage) · [Architecture](#architecture)
[Features](#features) · [Installation](#installation) · [Configuration](#configuration) · [Usage](#usage)

</div>

Expand Down Expand Up @@ -204,7 +204,7 @@ require("differ").setup({

## Usage

`:Differ [revspec]` diffs the current file against a resolved source. The grammar mirrors git:
`:Differ [revspec]` opens the file panel over the changed files for a resolved source, landing on the file you ran it from, or on the first file in the list when that file isn't one of them. The grammar mirrors git:

| Command | Diffs |
|---|---|
Expand Down Expand Up @@ -355,7 +355,7 @@ Code-comment threads render as a contained box (GitHub's outline, differ's left-

#### Merge tool

`:Differ mergetool [path]` opens it: with no argument it takes the current file when that's one of the conflicted ones, else the only conflicted file in the tree, else it offers a picker over them. Keys are bound on the result buffer.
`:Differ mergetool [path]` opens it: with no argument it takes the current file when that's one of the conflicted ones, else the only conflicted file in the tree, else it offers a picker over them. A bare `:Differ` lands here too whenever the tree has conflicts, on the same target - mid-merge the thing you want is to resolve, not to diff. Only the no-argument form reroutes; `:Differ <rev>` still opens that diff. Keys are bound on the result buffer.

| Key | Action |
|---|---|
Expand Down
10 changes: 7 additions & 3 deletions doc/differ.txt
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ Table of Contents *differ-table-of-contents*
==============================================================================
4. Usage *differ-usage*

`:Differ [revspec]` diffs the current file against a resolved source. The
grammar mirrors git:
`:Differ [revspec]` opens the file panel over the changed files for a resolved
source, landing on the file you ran it from, or on the first file in the list
when that file isn’t one of them. The grammar mirrors git:

Command Diffs
------------------- ---------------------------------------
Expand Down Expand Up @@ -406,7 +407,10 @@ MERGE TOOL ~

`:Differ mergetool [path]` opens it: with no argument it takes the current file
when that’s one of the conflicted ones, else the only conflicted file in the
tree, else it offers a picker over them. Keys are bound on the result buffer.
tree, else it offers a picker over them. A bare `:Differ` lands here too
whenever the tree has conflicts, on the same target - mid-merge the thing you
want is to resolve, not to diff. Only the no-argument form reroutes; `:Differ
<rev>` still opens that diff. Keys are bound on the result buffer.

Key Action
---------------------- ------------------------------
Expand Down
Loading
Loading