Skip to content
Open
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
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# drift fingerprints working-tree file content and records those fingerprints
# in drift.lock, which every platform that checks this repo out then shares. Git
# for Windows enables core.autocrlf by default, so without this a Windows
# checkout gets CRLF, every fingerprint differs from the one Linux recorded, and
# `drift check` reports the whole repo stale. `zig fmt` also rejects CRLF.
#
# Pin the working tree to LF everywhere. `text=auto` still leaves binary content
# alone.
* text=auto eol=lf
43 changes: 41 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ jobs:
- name: Lint specs
run: ./zig-out/bin/drift lint

# Windows is the one platform where paths, line endings, and the executable
# suffix differ, so it gets a native test run rather than a cross-compile.
test-windows:
name: Test (windows)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0

- name: Build and test
run: zig build test -Doptimize=ReleaseSafe

- name: Lint specs
run: ./zig-out/bin/drift.exe lint

build:
name: Build (${{ matrix.target }})
needs: [lint]
Expand All @@ -59,6 +81,15 @@ jobs:
- target: aarch64-linux
runner: ubuntu-22.04
zig-target: aarch64-linux-gnu
- target: x86_64-windows
runner: ubuntu-22.04
zig-target: x86_64-windows-gnu
zig-cpu: baseline
windows: true
- target: aarch64-windows
runner: ubuntu-22.04
zig-target: aarch64-windows-gnu
windows: true

steps:
- name: Checkout
Expand All @@ -74,11 +105,19 @@ jobs:
- name: Build
run: zig build -Doptimize=ReleaseSafe -Dtarget=${{ matrix.zig-target }} ${{ matrix.zig-cpu && format('-Dcpu={0}', matrix.zig-cpu) || '' }}

# Windows can open a .zip without extra tooling but not a .tar.gz, so
# Windows builds ship as .zip and everything else as .tar.gz.
- name: Package
run: tar -czf drift-${{ matrix.target }}.tar.gz -C zig-out/bin drift
run: |
if [ -n "${{ matrix.windows }}" ]; then
zip -q -j -X drift-${{ matrix.target }}.zip zig-out/bin/drift.exe
else
tar -czf drift-${{ matrix.target }}.tar.gz -C zig-out/bin drift
fi

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: drift-${{ matrix.target }}
path: drift-${{ matrix.target }}.tar.gz
path: drift-${{ matrix.target }}.*
if-no-files-found: error
32 changes: 25 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ jobs:
- target: aarch64-linux
runner: ubuntu-22.04
zig-target: aarch64-linux-gnu
- target: x86_64-windows
runner: ubuntu-22.04
zig-target: x86_64-windows-gnu
zig-cpu: baseline
windows: true
- target: aarch64-windows
runner: ubuntu-22.04
zig-target: aarch64-windows-gnu
windows: true

steps:
- name: Checkout
Expand All @@ -81,24 +90,30 @@ jobs:
- name: Build
run: zig build -Doptimize=ReleaseSafe -Dversion=${{ github.ref_name }} -Dtarget=${{ matrix.zig-target }} ${{ matrix.zig-cpu && format('-Dcpu={0}', matrix.zig-cpu) || '' }}

# Windows can open a .zip without extra tooling but not a .tar.gz, so
# Windows builds ship as .zip and everything else as .tar.gz.
- name: Package
run: |
TAR="drift-${{ matrix.target }}.tar.gz"
tar -czf "$TAR" -C zig-out/bin drift
if [ -n "${{ matrix.windows }}" ]; then
ARCHIVE="drift-${{ matrix.target }}.zip"
zip -q -j -X "$ARCHIVE" zig-out/bin/drift.exe
else
ARCHIVE="drift-${{ matrix.target }}.tar.gz"
tar -czf "$ARCHIVE" -C zig-out/bin drift
fi

if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$TAR" | cut -d' ' -f1 > "$TAR.sha256"
sha256sum "$ARCHIVE" | cut -d' ' -f1 > "$ARCHIVE.sha256"
else
shasum -a 256 "$TAR" | cut -d' ' -f1 > "$TAR.sha256"
shasum -a 256 "$ARCHIVE" | cut -d' ' -f1 > "$ARCHIVE.sha256"
fi

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: drift-${{ matrix.target }}
path: |
drift-${{ matrix.target }}.tar.gz
drift-${{ matrix.target }}.tar.gz.sha256
path: drift-${{ matrix.target }}.*
if-no-files-found: error

release:
name: Create release
Expand All @@ -117,9 +132,12 @@ jobs:
name: ${{ github.ref_name }}
body: ${{ needs.generate-notes.outputs.body }}
prerelease: false
fail_on_unmatched_files: true
files: |
drift-*.tar.gz
drift-*.tar.gz.sha256
drift-*.zip
drift-*.zip.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ Or build from source:
zig build -Doptimize=ReleaseSafe --prefix ~/.local
```

### Windows

Native `x86_64` and `aarch64` builds ship as `drift-<arch>-windows.zip` on every
[release](https://github.com/fiberplane/drift/releases). Unzip `drift.exe` onto
your `PATH`:

```powershell
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'aarch64' } else { 'x86_64' }
$dest = "$env:LOCALAPPDATA\Programs\drift"
Invoke-WebRequest -Uri "https://github.com/fiberplane/drift/releases/latest/download/drift-$arch-windows.zip" -OutFile "$env:TEMP\drift.zip"
Expand-Archive -Path "$env:TEMP\drift.zip" -DestinationPath $dest -Force
# Read the User Path unexpanded and write it back as REG_EXPAND_SZ, so
# %VAR%-style entries survive; skip the append if drift is already on it.
$path = (Get-Item HKCU:\Environment).GetValue('Path', '', 'DoNotExpandEnvironmentNames')
if (($path -split ';') -notcontains $dest) {
Set-ItemProperty HKCU:\Environment -Name Path -Value (@($path, $dest) -ne '' -join ';') -Type ExpandString
}
```

drift shells out to `git`, so [Git for Windows](https://git-scm.com/download/win)
needs to be installed too. Nothing else is required — drift reads CRLF working
trees as LF, so a `drift.lock` written on Windows matches one written on Linux.

Or build from source and copy the binary into the same directory:

```powershell
zig build -Doptimize=ReleaseSafe
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\Programs\drift" | Out-Null
Copy-Item zig-out\bin\drift.exe "$env:LOCALAPPDATA\Programs\drift\"
```

### Coding agent skill (Claude Code, Codex)

```bash
Expand Down
5 changes: 4 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ pub fn build(b: *std.Build) void {

// Tests — build options for integration tests
const test_options = b.addOptions();
test_options.addOption([]const u8, "drift_bin", b.getInstallPath(.bin, "drift"));
// Integration tests spawn the installed binary by path, so it needs the
// host's executable extension (`drift.exe` on Windows).
const drift_bin_name = b.fmt("drift{s}", .{target.result.exeFileExt()});
test_options.addOption([]const u8, "drift_bin", b.getInstallPath(.bin, drift_bin_name));

// Property-test seed. Defaults to the git HEAD hash (first 16 hex chars as
// u64) so each commit explores a different slice of the state space; pass
Expand Down
5 changes: 5 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ relinked all anchors in docs/auth.md

Each anchor gets its own content signature computed from the current file on disk.

**Path normalization** — doc and target paths are recorded relative to the
lockfile root with `/` separators, whatever the shell passed in. On Windows,
`drift link docs\auth.md src\auth\session.ts` records `docs/auth.md` and
`src/auth/session.ts`, so the lockfile stays identical across platforms.

**Relink gate** — when relinking a stale anchor (target signature changed), the relink is refused and both sides are printed (doc section and current code). This prevents blindly restamping without reviewing documentation. Pass `--doc-is-still-accurate` to confirm you've reviewed the doc and it doesn't need changes.

## drift unlink
Expand Down
35 changes: 35 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,38 @@ We use tree-sitter for link extraction rather than regex because:
- Tree-sitter markdown's `section` node provides heading-to-body grouping, which regex cannot reliably determine

The two-parser architecture requires two passes per file: block grammar first (producing `inline` node ranges), then inline grammar on those ranges. This adds build complexity (two grammar C sources, two `ts.Language` instances) but is how the grammar is designed — block and inline are separate grammars with separate node types.

## 15. Repo-relative paths are POSIX; file content is read as LF

`drift.lock` is committed and shared by every platform that checks the repo out,
so both halves of a binding have to mean the same thing everywhere.

**Paths.** A repo-relative path is normalized to `/` at the point it is produced
(`src/repo_path.zig`), not at the point it is written. Doc discovery matches
lockfile bindings against `git ls-files` output, and git speaks POSIX separators
on every platform while `std.Io.Dir.path` speaks the host separator — so on
Windows a doc discovered as `docs/a.md` would never match a binding stored as
`docs\a.md`. Normalizing on the way out also lets a Windows shell pass
`docs\a.md` to `drift link` and get a portable lockfile back. Absolute paths are
left in host form: they never leave the process.

**Content.** Working-tree file content is read with CRLF collapsed to LF
(`src/content.zig`). Git for Windows enables `core.autocrlf` by default, so the
same commit yields different bytes on different machines; without this,
fingerprints would track the checkout rather than the content and every anchor
would read stale on Windows. This affects any fingerprint that reaches raw bytes
— the no-grammar fallback, markdown sections — and also grammar-based ones,
since a line comment's token text runs to the end of the line and would swallow
the `\r`. Content that is already LF-only hashes unchanged, so lockfiles written
before this normalization stay valid for LF-only files; a text file whose
committed bytes genuinely contain CRLF (e.g. `eol=crlf` attributes) re-fingerprints
once and needs a relink.

Content that looks binary — a NUL byte in the first 8000 bytes, git's own
heuristic — is left untouched: autocrlf never rewrites binaries, so their bytes
already match across platforms, and collapsing CRLF there would make a CR-only
change invisible to the raw-byte fallback hash. A lone CR is left alone; nothing
in this pipeline produces one.

This repo also pins its own working tree to LF via `.gitattributes`, which is a
separate concern: it keeps `zig fmt` happy for Windows contributors.
10 changes: 10 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,23 @@ Every command creates two arena allocators backed by the GPA in `main()`. The **
Additional modules:
- `lockfile.zig` — read, write, and query `drift.lock` bindings; TOML parser and serializer
- `markdown.zig` — markdown parsing via tree-sitter (block + inline grammars): link extraction, heading resolution, section fingerprinting
- `repo_path.zig` — normalizes repo-relative paths to POSIX separators
- `content.zig` — reads working-tree file content with CRLF collapsed to LF (binary content untouched)
- `main.zig` — CLI entry point, argument parsing, subcommand dispatch
- `commands/lint.zig` — lint engine: file/content caching, anchor staleness checks, report formatting
- `commands/status.zig` — doc listing in text and JSON formats
- `commands/link.zig` — anchor linking with auto-provenance (content signatures)
- `commands/unlink.zig` — anchor removal from lockfile
- `commands/refs.zig` — reverse lookup: which docs reference a given target

### Cross-platform identity

`drift.lock` is committed, so both halves of a binding have to mean the same
thing on every platform that checks the repo out. Repo-relative paths are
normalized to `/` where they are produced (`repo_path.zig`), and working-tree
content is read with CRLF collapsed to LF unless it looks binary
(`content.zig`). Rationale and edge cases: Decision 15 in `DECISIONS.md`.

### lockfile.zig

Reads and writes `drift.lock`. The on-disk format is TOML array-of-tables: each `[[bindings]]` block contains `doc`, `target`, and metadata keys such as `sig` and `origin`. Parsing skips blank lines and comments, accepts bindings in any order, and also imports the legacy line format for upgrade-on-write compatibility. Writing canonicalizes each binding before output: metadata fields are sorted by key, then blocks are sorted by doc/target and separated by one blank line.
Expand Down
8 changes: 5 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ Every push to `main` and every pull request runs the CI workflow (`.github/workf

The **lint** job: install Zig 0.16.0, build the project, run the full test suite (`zig build test -Doptimize=ReleaseSafe`), regenerate `docs/schemas/drift.check.v1.json` from the payload types and fail if that file differs from what is committed (`zig build gen-check-schema` plus `git diff --exit-code`), then run `./zig-out/bin/drift lint` so the repo’s own drift docs stay current. If any step fails, the job fails.

The **build** job runs after **lint** and cross-compiles release binaries for all four targets (aarch64-macos, x86_64-macos, x86_64-linux, aarch64-linux), packaging each as a tarball artifact.
The **test-windows** job runs the same build and test suite on a `windows-latest` runner, then `drift lint`. Windows is the one platform where path separators, line endings, and the executable suffix differ, so it is tested natively rather than only cross-compiled.

The **build** job runs after **lint** (test-windows gates merges as its own status check, in parallel) and cross-compiles release binaries for all six targets (aarch64-macos, x86_64-macos, x86_64-linux, aarch64-linux, x86_64-windows, aarch64-windows). Windows builds are packaged as `.zip` — Windows opens those without extra tooling but not `.tar.gz` — and everything else as a tarball.

## Releasing a version

Expand Down Expand Up @@ -38,8 +40,8 @@ Types `chore`, `style`, and `ci` are excluded from changelogs. Merge commits are
```
3. The tag push triggers `.github/workflows/release.yml`, which first verifies the tag points to a commit on `main` (tags on feature branches are rejected), then:
- Generates release notes with git-cliff (grouped by Features, Bug Fixes, Documentation, Refactor)
- Cross-compiles for all 4 targets with Zig 0.16.0
- Creates a GitHub release with the generated notes, all tarballs, and matching `.sha256` checksum files attached
- Cross-compiles for all 6 targets with Zig 0.16.0
- Creates a GitHub release with the generated notes, all tarballs and Windows zips, and matching `.sha256` checksum files attached
- Optionally dispatches `fiberplane/homebrew-tap` to open or refresh the Homebrew formula PR for that tag

### Homebrew tap updates
Expand Down
26 changes: 18 additions & 8 deletions drift.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ sig = "84da70be235ca9d4"
[[bindings]]
doc = "CLAUDE.md"
target = "build.zig"
sig = "2dccb33f6b790afa"
sig = "46dc6d7f3bcf51a8"

[[bindings]]
doc = "CLAUDE.md"
Expand All @@ -25,17 +25,17 @@ sig = "f3b812f15563f0a2"
[[bindings]]
doc = "docs/CLI.md"
target = "src/commands/link.zig"
sig = "3ae8f4ee2c85d8d8"
sig = "c87c4a5ee23cadc9"

[[bindings]]
doc = "docs/CLI.md"
target = "src/commands/lint.zig"
sig = "270d047d8cbaf238"
sig = "79978e9c11d55527"

[[bindings]]
doc = "docs/CLI.md"
target = "src/commands/refs.zig"
sig = "f623b7774086094e"
sig = "2c07dae18d4ebe25"

[[bindings]]
doc = "docs/CLI.md"
Expand All @@ -45,7 +45,12 @@ sig = "eade166d24a20b81"
[[bindings]]
doc = "docs/CLI.md"
target = "src/commands/unlink.zig"
sig = "0dbe1ee3315211b5"
sig = "d938905bf6073cea"

[[bindings]]
doc = "docs/DESIGN.md"
target = "src/content.zig"
sig = "6d381fc98eb032b0"

[[bindings]]
doc = "docs/DESIGN.md"
Expand All @@ -55,13 +60,18 @@ sig = "82d9da38ea486f36"
[[bindings]]
doc = "docs/DESIGN.md"
target = "src/lockfile.zig"
sig = "55bc77a2853cb654"
sig = "11c5ffbe19e53453"

[[bindings]]
doc = "docs/DESIGN.md"
target = "src/main.zig"
sig = "f3b812f15563f0a2"

[[bindings]]
doc = "docs/DESIGN.md"
target = "src/repo_path.zig"
sig = "fc55dc2a392f67e6"

[[bindings]]
doc = "docs/DESIGN.md"
target = "src/symbols.zig"
Expand All @@ -85,12 +95,12 @@ sig = "7d0fe37e5eff5e30"
[[bindings]]
doc = "docs/RELEASING.md"
target = ".github/workflows/ci.yml"
sig = "c14a23e6547d575f"
sig = "15de5934583eb324"

[[bindings]]
doc = "docs/RELEASING.md"
target = ".github/workflows/release.yml"
sig = "19b334776bec1eda"
sig = "4ce4ca9a5516959a"

[[bindings]]
doc = "docs/RELEASING.md"
Expand Down
Loading