From 0acd5b7da7be3eb1715c90c2cfbad250f735e03a Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Mon, 24 Aug 2026 12:53:19 -0700 Subject: [PATCH 1/4] fix: make drift.lock identical on Windows and POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drift compiled on Windows already, but nothing it wrote there was portable: `drift check` reported every doc in the repo stale. Two things differed. Repo-relative paths came out of `std.Io.Dir.path.relative` with host separators, so a binding stored as `docs\a.md` never matched the `docs/a.md` that `git ls-files` reports during doc discovery. And Git for Windows turns on `core.autocrlf` by default, so the working tree holds CRLF where Linux holds LF, and every fingerprint that reaches raw bytes changed with the checkout rather than the content — the no-grammar fallback and markdown sections directly, and grammar-based fingerprints too, since a line comment's token text runs to end-of-line and swallows the CR. Normalize both at the boundary: `repo_path.normalize` rewrites a repo-relative path to POSIX separators where it is produced, and `content.normalizeLineEndings` collapses CRLF as working-tree files are read. Absolute paths keep host form — they never leave the process. LF-only content hashes unchanged, so lockfiles written before this stay valid. Also point the integration tests at `drift.exe` explicitly rather than relying on CreateProcess probing for the extension. Co-Authored-By: Claude Opus 5 (1M context) --- build.zig | 5 +++- docs/CLI.md | 5 ++++ docs/DECISIONS.md | 28 +++++++++++++++++++ docs/DESIGN.md | 19 +++++++++++++ drift.lock | 26 ++++++++++++------ src/commands/link.zig | 12 +++++---- src/commands/lint.zig | 10 ++++--- src/commands/refs.zig | 3 ++- src/commands/unlink.zig | 5 ++-- src/content.zig | 49 ++++++++++++++++++++++++++++++++++ src/lockfile.zig | 10 ++++--- src/repo_path.zig | 28 +++++++++++++++++++ test/integration/link_test.zig | 27 +++++++++++++++++++ test/integration/lint_test.zig | 31 +++++++++++++++++++++ tests.zig | 2 ++ 15 files changed, 236 insertions(+), 24 deletions(-) create mode 100644 src/content.zig create mode 100644 src/repo_path.zig diff --git a/build.zig b/build.zig index 74405ba..0fecd45 100644 --- a/build.zig +++ b/build.zig @@ -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 diff --git a/docs/CLI.md b/docs/CLI.md index 3250a95..41a86f1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7af7c49..1439df2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -160,3 +160,31 @@ 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. 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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f2607f7..ccf14a2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -184,6 +184,8 @@ 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 - `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 @@ -191,6 +193,23 @@ Additional modules: - `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. + +A repo-relative path is normalized to `/` where it is produced, not where it is +written (`repo_path.zig`). Doc discovery matches bindings against `git ls-files`, +which is POSIX everywhere, while `std.Io.Dir.path` follows the host — so on +Windows a doc discovered as `docs/a.md` would never match a binding stored as +`docs\a.md`. Absolute paths stay in host form; they never leave the process. + +Working-tree content is read with CRLF collapsed to LF (`content.zig`). Git for +Windows turns on `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. Content that is already LF-only hashes unchanged, so +existing lockfiles stay valid. See 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. diff --git a/drift.lock b/drift.lock index b07b600..7ce7552 100644 --- a/drift.lock +++ b/drift.lock @@ -15,7 +15,7 @@ sig = "84da70be235ca9d4" [[bindings]] doc = "CLAUDE.md" target = "build.zig" -sig = "2dccb33f6b790afa" +sig = "46dc6d7f3bcf51a8" [[bindings]] doc = "CLAUDE.md" @@ -25,17 +25,17 @@ sig = "f3b812f15563f0a2" [[bindings]] doc = "docs/CLI.md" target = "src/commands/link.zig" -sig = "3ae8f4ee2c85d8d8" +sig = "09d9f9988209c46b" [[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" @@ -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 = "cb5d9716d689d3ef" [[bindings]] doc = "docs/DESIGN.md" @@ -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" @@ -85,12 +95,12 @@ sig = "7d0fe37e5eff5e30" [[bindings]] doc = "docs/RELEASING.md" target = ".github/workflows/ci.yml" -sig = "c14a23e6547d575f" +sig = "d0c45a62e628ba46" [[bindings]] doc = "docs/RELEASING.md" target = ".github/workflows/release.yml" -sig = "19b334776bec1eda" +sig = "ee4dcab45f915ace" [[bindings]] doc = "docs/RELEASING.md" diff --git a/src/commands/link.zig b/src/commands/link.zig index a6a7979..4929b97 100644 --- a/src/commands/link.zig +++ b/src/commands/link.zig @@ -1,7 +1,9 @@ const std = @import("std"); const CommandContext = @import("../context.zig").CommandContext; +const content_mod = @import("../content.zig"); const lockfile = @import("../lockfile.zig"); const markdown = @import("../markdown.zig"); +const repo_path = @import("../repo_path.zig"); const symbols = @import("../symbols.zig"); const target = @import("../target.zig"); @@ -24,10 +26,10 @@ pub fn run( var lf = try lockfile.discover(ctx.io, ctx.run_arena, ctx.scratch(), doc_dir); ctx.resetScratch(); - const doc_content = std.Io.Dir.cwd().readFileAlloc(ctx.io, doc_path, ctx.run_arena, .limited(1024 * 1024)) catch |err| { + const doc_content = content_mod.normalizeLineEndings(std.Io.Dir.cwd().readFileAlloc(ctx.io, doc_path, ctx.run_arena, .limited(1024 * 1024)) catch |err| { stderr_w.print("error: cannot read '{s}': {s}\n", .{ doc_path, @errorName(err) }) catch {}; return error.DocReadFailed; - }; + }); const normalized_doc_path = try normalizeDocPath(ctx, lf.root_path, cwd_path, doc_path); ctx.resetScratch(); @@ -238,7 +240,7 @@ fn normalizeDocPath( doc_path: []const u8, ) ![]const u8 { const absolute = try resolveInputPath(ctx, root_path, cwd_path, doc_path); - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); ctx.resetScratch(); return relative; } @@ -256,7 +258,7 @@ fn normalizeTargetPath( return error.TargetNotFound; } - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); if (parsed.symbol_name) |symbol| { if (parsed.isHeading()) { @@ -308,7 +310,7 @@ fn readResolvedFile(ctx: CommandContext, path: []const u8) ![]const u8 { try std.Io.Dir.cwd().openFile(ctx.io, path, .{}); defer file.close(ctx.io); var file_reader = file.reader(ctx.io, &.{}); - return try file_reader.interface.allocRemaining(ctx.scratch(), .limited(1024 * 1024)); + return content_mod.normalizeLineEndings(try file_reader.interface.allocRemaining(ctx.scratch(), .limited(1024 * 1024))); } fn findBinding(bindings: []lockfile.Binding, doc_path: []const u8, normalized_target: []const u8) ?*lockfile.Binding { diff --git a/src/commands/lint.zig b/src/commands/lint.zig index 46d82ab..99e49a4 100644 --- a/src/commands/lint.zig +++ b/src/commands/lint.zig @@ -2,8 +2,10 @@ const std = @import("std"); const build_options = @import("build_options"); const drift_check_v1 = @import("payload"); const CommandContext = @import("../context.zig").CommandContext; +const content_mod = @import("../content.zig"); const lockfile = @import("../lockfile.zig"); const markdown = @import("../markdown.zig"); +const repo_path = @import("../repo_path.zig"); const symbols = @import("../symbols.zig"); const target = @import("../target.zig"); const vcs = @import("../vcs.zig"); @@ -40,7 +42,7 @@ const FileCache = struct { const a = self.arena.allocator(); var file_reader = file.reader(self.io, &.{}); - const content = try file_reader.interface.allocRemaining(a, .limited(1024 * 1024)); + const content = content_mod.normalizeLineEndings(try file_reader.interface.allocRemaining(a, .limited(1024 * 1024))); const key = try a.dupe(u8, absolute_path); try self.current.put(key, content); return content; @@ -630,7 +632,7 @@ fn classifyLinkTask( if (path_part.len == 0) return; const absolute = std.Io.Dir.path.resolve(run_arena, &.{ doc_dir, path_part }) catch return; - const relative = std.Io.Dir.path.relative(run_arena, "", null, root_path, absolute) catch return; + const relative = repo_path.normalize(std.Io.Dir.path.relative(run_arena, "", null, root_path, absolute) catch return); const exists = pathExists(io, absolute); slot.* = .{ @@ -677,11 +679,11 @@ fn normalizeChangedPrefix( raw_path: []const u8, ) ![]const u8 { if (std.Io.Dir.path.isAbsolute(raw_path)) { - return try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, raw_path); + return repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, raw_path)); } const absolute = try std.Io.Dir.path.resolve(ctx.scratch(), &.{ cwd_path, raw_path }); - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); ctx.resetScratch(); return relative; } diff --git a/src/commands/refs.zig b/src/commands/refs.zig index e24ea83..3f90744 100644 --- a/src/commands/refs.zig +++ b/src/commands/refs.zig @@ -1,6 +1,7 @@ const std = @import("std"); const CommandContext = @import("../context.zig").CommandContext; const lockfile = @import("../lockfile.zig"); +const repo_path = @import("../repo_path.zig"); const target = @import("../target.zig"); pub fn run(ctx: CommandContext, stdout_w: *std.Io.Writer, stderr_w: *std.Io.Writer, raw_target: []const u8) !void { @@ -38,7 +39,7 @@ fn normalizeTargetPath( const symbol_name = parsed.symbol_name; const absolute = try resolveInputPath(ctx, root_path, cwd_path, file_part); - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); ctx.resetScratch(); if (symbol_name) |symbol| { diff --git a/src/commands/unlink.zig b/src/commands/unlink.zig index 4d4ba68..c9960ce 100644 --- a/src/commands/unlink.zig +++ b/src/commands/unlink.zig @@ -1,6 +1,7 @@ const std = @import("std"); const CommandContext = @import("../context.zig").CommandContext; const lockfile = @import("../lockfile.zig"); +const repo_path = @import("../repo_path.zig"); const target = @import("../target.zig"); pub fn run(ctx: CommandContext, stdout_w: *std.Io.Writer, stderr_w: *std.Io.Writer, doc_path: []const u8, anchor: []const u8) !void { @@ -48,7 +49,7 @@ fn normalizeSpecPath( doc_path: []const u8, ) ![]const u8 { const absolute = try resolveInputPath(ctx, root_path, cwd_path, doc_path); - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); ctx.resetScratch(); return relative; } @@ -64,7 +65,7 @@ fn normalizeTargetPath( const symbol_name = parsed.symbol_name; const absolute = try resolveInputPath(ctx, root_path, cwd_path, file_part); - const relative = try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute); + const relative = repo_path.normalize(try std.Io.Dir.path.relative(ctx.run_arena, "", null, root_path, absolute)); ctx.resetScratch(); if (symbol_name) |symbol| { diff --git a/src/content.zig b/src/content.zig new file mode 100644 index 0000000..ae476a6 --- /dev/null +++ b/src/content.zig @@ -0,0 +1,49 @@ +const std = @import("std"); + +/// drift compares a working-tree file against a fingerprint recorded in +/// `drift.lock`, and that lockfile is shared by every platform that checks the +/// repo out. Git rewrites LF to CRLF in the working tree when `core.autocrlf` +/// is on — the Windows default — so the same commit yields different bytes on +/// different machines. Fingerprints would then track the checkout rather than +/// the content, and every anchor would read stale on Windows. +/// +/// Reading CRLF as LF makes the fingerprint depend on content alone. Files that +/// are already LF-only come back byte-identical, so lockfiles written before +/// this normalization stay valid. +/// +/// A lone CR (classic Mac line ending) is left alone: no tooling in this +/// pipeline produces it, and rewriting it would change fingerprints for repos +/// that genuinely contain one. +pub fn normalizeLineEndings(buf: []u8) []u8 { + var out: usize = 0; + var i: usize = 0; + while (i < buf.len) : (i += 1) { + if (buf[i] == '\r' and i + 1 < buf.len and buf[i + 1] == '\n') continue; + buf[out] = buf[i]; + out += 1; + } + return buf[0..out]; +} + +test "normalizeLineEndings strips CR only before LF" { + var crlf = "a\r\nb\r\n".*; + try std.testing.expectEqualStrings("a\nb\n", normalizeLineEndings(&crlf)); + + var lf = "a\nb\n".*; + try std.testing.expectEqualStrings("a\nb\n", normalizeLineEndings(&lf)); + + var lone_cr = "a\rb".*; + try std.testing.expectEqualStrings("a\rb", normalizeLineEndings(&lone_cr)); + + var trailing_cr = "a\r".*; + try std.testing.expectEqualStrings("a\r", normalizeLineEndings(&trailing_cr)); +} + +test "normalizeLineEndings makes CRLF and LF sources hash alike" { + var crlf = "const a = 1;\r\n// note\r\n".*; + var lf = "const a = 1;\n// note\n".*; + try std.testing.expectEqualStrings( + normalizeLineEndings(&lf), + normalizeLineEndings(&crlf), + ); +} diff --git a/src/lockfile.zig b/src/lockfile.zig index fe202d1..e9722a7 100644 --- a/src/lockfile.zig +++ b/src/lockfile.zig @@ -1,5 +1,9 @@ const std = @import("std"); +/// Host path separator. `discover` returns absolute paths, which stay in host +/// form — only repo-relative paths are normalized to POSIX (see `repo_path`). +const sep = std.Io.Dir.path.sep_str; + pub const MetadataField = struct { key: []const u8, value: []const u8, @@ -818,8 +822,8 @@ test "discover walks up to find drift.lock" { try std.testing.expect(discovered.exists); try std.testing.expectEqual(@as(usize, 1), discovered.bindings.items.len); - try std.testing.expect(std.mem.endsWith(u8, discovered.root_path, "/repo")); - try std.testing.expect(std.mem.endsWith(u8, discovered.lockfile_path, "/repo/drift.lock")); + try std.testing.expect(std.mem.endsWith(u8, discovered.root_path, sep ++ "repo")); + try std.testing.expect(std.mem.endsWith(u8, discovered.lockfile_path, sep ++ "repo" ++ sep ++ "drift.lock")); } test "discover returns empty lockfile rooted at start path when missing" { @@ -846,5 +850,5 @@ test "discover returns empty lockfile rooted at start path when missing" { try std.testing.expect(!discovered.exists); try std.testing.expectEqual(@as(usize, 0), discovered.bindings.items.len); try std.testing.expectEqualStrings(start_path, discovered.root_path); - try std.testing.expect(std.mem.endsWith(u8, discovered.lockfile_path, "/repo/drift.lock")); + try std.testing.expect(std.mem.endsWith(u8, discovered.lockfile_path, sep ++ "repo" ++ sep ++ "drift.lock")); } diff --git a/src/repo_path.zig b/src/repo_path.zig new file mode 100644 index 0000000..1b60159 --- /dev/null +++ b/src/repo_path.zig @@ -0,0 +1,28 @@ +const std = @import("std"); + +/// Repo-relative paths are drift's canonical identity: they are written to +/// `drift.lock`, printed in reports, and matched against `git ls-files` output. +/// Git speaks POSIX separators on every platform, while `std.Io.Dir.path` +/// speaks the host separator, so on Windows the two disagree and a doc +/// discovered as `docs/a.md` never matches a binding stored as `docs\a.md`. +/// +/// Normalizing where a repo-relative path is produced keeps a lockfile written +/// on Windows byte-identical to one written on Linux. +const host_sep = std.Io.Dir.path.sep; + +/// Rewrites host separators to `/` in place and returns the same buffer. +/// +/// A no-op on POSIX hosts, where a backslash is a legal filename byte and must +/// be preserved. +pub fn normalize(path: []u8) []u8 { + if (host_sep != '/') std.mem.replaceScalar(u8, path, host_sep, '/'); + return path; +} + +test "normalize rewrites host separators only" { + var already_posix = "docs/a.md".*; + try std.testing.expectEqualStrings("docs/a.md", normalize(&already_posix)); + + var host_form = ("docs" ++ std.Io.Dir.path.sep_str ++ "nested" ++ std.Io.Dir.path.sep_str ++ "a.md").*; + try std.testing.expectEqualStrings("docs/nested/a.md", normalize(&host_form)); +} diff --git a/test/integration/link_test.zig b/test/integration/link_test.zig index e3a7003..8b143bb 100644 --- a/test/integration/link_test.zig +++ b/test/integration/link_test.zig @@ -40,6 +40,33 @@ test "link adds new file binding to drift.lock" { try std.testing.expectEqualStrings("# Doc\n", doc_content); } +test "link stores repo-relative paths with POSIX separators" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/doc.md", "# Doc\n"); + try repo.writeFile("src/new.ts", "export const value = 1;\n"); + try repo.commit("add doc and source"); + + // A Windows shell hands drift backslash-separated arguments, but the + // lockfile is shared across platforms and is matched against `git ls-files` + // output, which is POSIX everywhere. Degenerates to the plain form on POSIX + // hosts, where a backslash is an ordinary filename byte. + const sep = std.Io.Dir.path.sep_str; + const result = try repo.runDrift(&.{ "link", "docs" ++ sep ++ "doc.md", "src" ++ sep ++ "new.ts" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "added docs/doc.md -> src/new.ts sig:"); + + const lock_content = try repo.readFile("drift.lock"); + defer allocator.free(lock_content); + try helpers.expectContains(lock_content, "doc = \"docs/doc.md\"\n"); + try helpers.expectContains(lock_content, "target = \"src/new.ts\"\n"); + try helpers.expectNotContains(lock_content, "\\\\"); +} + test "link adds symbol binding to drift.lock" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator); diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 0459fce..938ede7 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -177,6 +177,37 @@ test "check ignores typescript formatting-only file change" { ); } +test "check ignores a CRLF-vs-LF checkout difference" { + const allocator = std.testing.allocator; + // Git rewrites LF to CRLF on checkout when core.autocrlf is on, the Windows + // default. A fingerprint recorded on one platform has to keep matching on + // the other, including for extensions that have no tree-sitter grammar and + // fall back to hashing raw bytes. + try expectFormattingOnlyFileChangeIsFresh( + allocator, + "src/math.ts", + "function add(a: number, b: number): number {\r\n return a + b;\r\n}\r\n", + "function add(a: number, b: number): number {\n return a + b;\n}\n", + ); + try expectFormattingOnlyFileChangeIsFresh( + allocator, + "config/settings.conf", + "listen = 8080\r\nworkers = 4\r\n", + "listen = 8080\nworkers = 4\n", + ); +} + +test "check ignores a CRLF-vs-LF checkout difference for markdown headings" { + const allocator = std.testing.allocator; + try expectFormattingOnlySymbolChangeIsFresh( + allocator, + "docs/auth.md", + "docs/auth.md#Token Validation", + "# Auth\r\n\r\n## Token Validation\r\n\r\nTokens expire after an hour.\r\n", + "# Auth\n\n## Token Validation\n\nTokens expire after an hour.\n", + ); +} + test "check ignores python formatting-only file change" { const allocator = std.testing.allocator; try expectFormattingOnlyFileChangeIsFresh( diff --git a/tests.zig b/tests.zig index 73cb030..0518d55 100644 --- a/tests.zig +++ b/tests.zig @@ -1,6 +1,8 @@ test { _ = @import("src/main.zig"); _ = @import("src/lockfile.zig"); + _ = @import("src/repo_path.zig"); + _ = @import("src/content.zig"); _ = @import("test/payload_validate_test.zig"); // Integration tests From 4d8809ef0696b94228da5f554a21a76124deb72a Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Mon, 24 Aug 2026 12:54:13 -0700 Subject: [PATCH 2/4] fix(link): copy a binding's signature before restamping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Binding.setField` frees the previous value, so the `old_sig` slice the relink gate holds across `refreshBindingSig` dangles, and `isDocGateBlocked` compares against freed memory. Whether a relink is refused or waved through then depends on what the allocator happened to leave behind — on Windows, `drift link ` refused every anchor in the doc, including ones whose fingerprint had not moved. Dupe the signature into the run arena before restamping. Co-Authored-By: Claude Opus 5 (1M context) --- drift.lock | 2 +- src/commands/link.zig | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/drift.lock b/drift.lock index 7ce7552..141defd 100644 --- a/drift.lock +++ b/drift.lock @@ -25,7 +25,7 @@ sig = "f3b812f15563f0a2" [[bindings]] doc = "docs/CLI.md" target = "src/commands/link.zig" -sig = "09d9f9988209c46b" +sig = "c110592afdb3cfd0" [[bindings]] doc = "docs/CLI.md" diff --git a/src/commands/link.zig b/src/commands/link.zig index 4929b97..0970834 100644 --- a/src/commands/link.zig +++ b/src/commands/link.zig @@ -49,7 +49,7 @@ pub fn run( ctx.resetScratch(); const existing_binding = findBinding(lf.bindings.items, normalized_doc_path, normalized_target); - const old_sig = if (existing_binding) |b| b.fieldValue("sig") else null; + const old_sig = if (existing_binding) |b| try copySig(ctx, b) else null; upsertBinding(ctx, &lf, cwd_path, normalized_doc_path, normalized_target) catch |err| switch (err) { error.CannotComputeFingerprint => { @@ -80,7 +80,7 @@ pub fn run( var refused_count: usize = 0; for (lf.bindings.items) |*binding| { if (!std.mem.eql(u8, binding.doc_path, normalized_doc_path)) continue; - const old_sig = binding.fieldValue("sig"); + const old_sig = try copySig(ctx, binding); refreshBindingSig(ctx, cwd_path, lf.root_path, binding) catch |err| switch (err) { error.CannotComputeFingerprint => { stderr_w.print("error: cannot compute fingerprint for target: {s}\n", .{binding.target}) catch {}; @@ -129,6 +129,17 @@ fn promptDocAccurate(io: std.Io, stderr_w: *std.Io.Writer) bool { return answer.len > 0 and (answer[0] == 'y' or answer[0] == 'Y'); } +/// Copy a binding's current signature out before restamping it. +/// +/// `Binding.setField` frees the previous value, so a slice held across +/// `refreshBindingSig` dangles and the gate below would compare against freed +/// memory — refusing or waving through a relink depending on what the allocator +/// happened to leave there. +fn copySig(ctx: CommandContext, binding: *const lockfile.Binding) !?[]const u8 { + const sig = binding.fieldValue("sig") orelse return null; + return try ctx.run_arena.dupe(u8, sig); +} + /// Returns true when a relink should be refused: target changed without review. fn isDocGateBlocked( binding: *lockfile.Binding, From d5fa863e16ec3fdc78a1391dccef2e3523e4d93c Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Mon, 24 Aug 2026 12:54:21 -0700 Subject: [PATCH 3/4] ci: test natively on Windows and publish Windows artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `test-windows` job on `windows-latest` running the same build, test suite, and `drift lint` as the Linux job. Windows is the one platform where path separators, line endings, and the executable suffix differ, so cross-compiling it without ever running it is what let those bugs sit. Adds `x86_64-windows` and `aarch64-windows` to the CI and release build matrices, cross-compiled from Linux as the other targets are. They ship as `.zip` rather than `.tar.gz` — Windows opens a zip without extra tooling. `.gitattributes` pins the working tree to LF. drift itself no longer cares after the previous commits, but `zig fmt` rejects CRLF, so without this a Windows contributor cannot format the repo. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 9 +++++++ .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++--- .github/workflows/release.yml | 29 +++++++++++++++++++---- README.md | 23 ++++++++++++++++++ docs/RELEASING.md | 8 ++++--- 5 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..971cbe1 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5964d4..b170b95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,31 @@ 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] + needs: [lint, test-windows] runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -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 @@ -74,11 +105,18 @@ 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 }}.* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a47532..4ac0391 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -81,15 +90,22 @@ 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 @@ -99,6 +115,9 @@ jobs: path: | drift-${{ matrix.target }}.tar.gz drift-${{ matrix.target }}.tar.gz.sha256 + drift-${{ matrix.target }}.zip + drift-${{ matrix.target }}.zip.sha256 + if-no-files-found: ignore release: name: Create release @@ -120,6 +139,8 @@ jobs: files: | drift-*.tar.gz drift-*.tar.gz.sha256 + drift-*.zip + drift-*.zip.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index ccab8df..2d81f8b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,29 @@ Or build from source: zig build -Doptimize=ReleaseSafe --prefix ~/.local ``` +### Windows + +Native `x86_64` and `aarch64` builds ship as `drift--windows.zip` on every +[release](https://github.com/fiberplane/drift/releases). Unzip `drift.exe` onto +your `PATH`: + +```powershell +$dest = "$env:LOCALAPPDATA\Programs\drift" +Invoke-WebRequest -Uri https://github.com/fiberplane/drift/releases/latest/download/drift-x86_64-windows.zip -OutFile "$env:TEMP\drift.zip" +Expand-Archive -Path "$env:TEMP\drift.zip" -DestinationPath $dest -Force +[Environment]::SetEnvironmentVariable("Path", "$([Environment]::GetEnvironmentVariable('Path', 'User'));$dest", "User") +``` + +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: + +```powershell +zig build -Doptimize=ReleaseSafe --prefix $env:LOCALAPPDATA\Programs\drift +``` + ### Coding agent skill (Claude Code, Codex) ```bash diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 155b6a4..1f9552b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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** and **test-windows** 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 @@ -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 From 00dff1ac8a545342bc0e13ffbd8564563f3c1f28 Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Mon, 24 Aug 2026 14:18:29 -0700 Subject: [PATCH 4/4] fix: address review findings on the Windows-native work - content: skip CRLF normalization for binary-looking content (git's NUL-in-first-8000-bytes heuristic) so a CR-only change in a raw-hash target stays detectable, and early-out on buffers with no CR - docs: qualify the lockfile-compatibility claim (committed-CRLF text re-fingerprints once and needs a relink), document the binary carve-out, and collapse DESIGN.md's restatement of Decision 15 - release workflow: fail loudly when an archive goes missing (if-no-files-found: error, fail_on_unmatched_files) instead of publishing a release without it - ci: build matrix no longer waits on test-windows (it still gates merges as its own status check); strict artifact upload - README: arch-aware Windows download, registry-safe PATH append that preserves REG_EXPAND_SZ entries and dedupes, from-source install that lands on PATH - link: hoist the doc read out of the normalize call argument; drop a redundant lockfile assertion in the integration test - relink drift anchors for the updated docs Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 9 +++------ README.md | 16 +++++++++++---- docs/DECISIONS.md | 11 +++++++++-- docs/DESIGN.md | 19 +++++------------- docs/RELEASING.md | 2 +- drift.lock | 8 ++++---- src/commands/link.zig | 5 +++-- src/content.zig | 36 +++++++++++++++++++++++++++++++--- test/integration/link_test.zig | 1 - 10 files changed, 72 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b170b95..8066668 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: build: name: Build (${{ matrix.target }}) - needs: [lint, test-windows] + needs: [lint] runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -120,3 +120,4 @@ jobs: with: name: drift-${{ matrix.target }} path: drift-${{ matrix.target }}.* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ac0391..e7313e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,12 +112,8 @@ jobs: uses: actions/upload-artifact@v4 with: name: drift-${{ matrix.target }} - path: | - drift-${{ matrix.target }}.tar.gz - drift-${{ matrix.target }}.tar.gz.sha256 - drift-${{ matrix.target }}.zip - drift-${{ matrix.target }}.zip.sha256 - if-no-files-found: ignore + path: drift-${{ matrix.target }}.* + if-no-files-found: error release: name: Create release @@ -136,6 +132,7 @@ 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 diff --git a/README.md b/README.md index 2d81f8b..06a06de 100644 --- a/README.md +++ b/README.md @@ -38,20 +38,28 @@ Native `x86_64` and `aarch64` builds ship as `drift--windows.zip` on every 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-x86_64-windows.zip -OutFile "$env:TEMP\drift.zip" +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 -[Environment]::SetEnvironmentVariable("Path", "$([Environment]::GetEnvironmentVariable('Path', 'User'));$dest", "User") +# 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: +Or build from source and copy the binary into the same directory: ```powershell -zig build -Doptimize=ReleaseSafe --prefix $env:LOCALAPPDATA\Programs\drift +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) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1439df2..e5cfaa3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -183,8 +183,15 @@ 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. A lone CR is left alone; nothing in this -pipeline produces one. +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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ccf14a2..509f8df 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -185,7 +185,7 @@ 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 +- `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 @@ -196,19 +196,10 @@ Additional modules: ### 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. - -A repo-relative path is normalized to `/` where it is produced, not where it is -written (`repo_path.zig`). Doc discovery matches bindings against `git ls-files`, -which is POSIX everywhere, while `std.Io.Dir.path` follows the host — so on -Windows a doc discovered as `docs/a.md` would never match a binding stored as -`docs\a.md`. Absolute paths stay in host form; they never leave the process. - -Working-tree content is read with CRLF collapsed to LF (`content.zig`). Git for -Windows turns on `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. Content that is already LF-only hashes unchanged, so -existing lockfiles stay valid. See Decision 15 in `DECISIONS.md`. +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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1f9552b..a0eebc9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,7 +11,7 @@ The **lint** job: install Zig 0.16.0, build the project, run the full test suite 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** and **test-windows** 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. +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 diff --git a/drift.lock b/drift.lock index 141defd..821334a 100644 --- a/drift.lock +++ b/drift.lock @@ -25,7 +25,7 @@ sig = "f3b812f15563f0a2" [[bindings]] doc = "docs/CLI.md" target = "src/commands/link.zig" -sig = "c110592afdb3cfd0" +sig = "c87c4a5ee23cadc9" [[bindings]] doc = "docs/CLI.md" @@ -50,7 +50,7 @@ sig = "d938905bf6073cea" [[bindings]] doc = "docs/DESIGN.md" target = "src/content.zig" -sig = "cb5d9716d689d3ef" +sig = "6d381fc98eb032b0" [[bindings]] doc = "docs/DESIGN.md" @@ -95,12 +95,12 @@ sig = "7d0fe37e5eff5e30" [[bindings]] doc = "docs/RELEASING.md" target = ".github/workflows/ci.yml" -sig = "d0c45a62e628ba46" +sig = "15de5934583eb324" [[bindings]] doc = "docs/RELEASING.md" target = ".github/workflows/release.yml" -sig = "ee4dcab45f915ace" +sig = "4ce4ca9a5516959a" [[bindings]] doc = "docs/RELEASING.md" diff --git a/src/commands/link.zig b/src/commands/link.zig index 0970834..7a98223 100644 --- a/src/commands/link.zig +++ b/src/commands/link.zig @@ -26,10 +26,11 @@ pub fn run( var lf = try lockfile.discover(ctx.io, ctx.run_arena, ctx.scratch(), doc_dir); ctx.resetScratch(); - const doc_content = content_mod.normalizeLineEndings(std.Io.Dir.cwd().readFileAlloc(ctx.io, doc_path, ctx.run_arena, .limited(1024 * 1024)) catch |err| { + const raw_doc_content = std.Io.Dir.cwd().readFileAlloc(ctx.io, doc_path, ctx.run_arena, .limited(1024 * 1024)) catch |err| { stderr_w.print("error: cannot read '{s}': {s}\n", .{ doc_path, @errorName(err) }) catch {}; return error.DocReadFailed; - }); + }; + const doc_content = content_mod.normalizeLineEndings(raw_doc_content); const normalized_doc_path = try normalizeDocPath(ctx, lf.root_path, cwd_path, doc_path); ctx.resetScratch(); diff --git a/src/content.zig b/src/content.zig index ae476a6..6e70be1 100644 --- a/src/content.zig +++ b/src/content.zig @@ -9,14 +9,22 @@ const std = @import("std"); /// /// Reading CRLF as LF makes the fingerprint depend on content alone. Files that /// are already LF-only come back byte-identical, so lockfiles written before -/// this normalization stay valid. +/// this normalization stay valid for them; a text file whose committed bytes +/// genuinely contain CRLF re-fingerprints once and needs a relink. +/// +/// Content that looks binary is left untouched: autocrlf never rewrites +/// binaries, so their bytes already match across platforms — and collapsing +/// CRLF there would make a CR-only difference invisible to the raw-byte +/// fallback hash. /// /// A lone CR (classic Mac line ending) is left alone: no tooling in this /// pipeline produces it, and rewriting it would change fingerprints for repos /// that genuinely contain one. pub fn normalizeLineEndings(buf: []u8) []u8 { - var out: usize = 0; - var i: usize = 0; + if (looksBinary(buf)) return buf; + const first_cr = std.mem.indexOfScalar(u8, buf, '\r') orelse return buf; + var out: usize = first_cr; + var i: usize = first_cr; while (i < buf.len) : (i += 1) { if (buf[i] == '\r' and i + 1 < buf.len and buf[i + 1] == '\n') continue; buf[out] = buf[i]; @@ -25,6 +33,12 @@ pub fn normalizeLineEndings(buf: []u8) []u8 { return buf[0..out]; } +/// Git's heuristic: a NUL byte in the first 8000 bytes marks content binary. +fn looksBinary(buf: []const u8) bool { + const window = buf[0..@min(buf.len, 8000)]; + return std.mem.indexOfScalar(u8, window, 0) != null; +} + test "normalizeLineEndings strips CR only before LF" { var crlf = "a\r\nb\r\n".*; try std.testing.expectEqualStrings("a\nb\n", normalizeLineEndings(&crlf)); @@ -47,3 +61,19 @@ test "normalizeLineEndings makes CRLF and LF sources hash alike" { normalizeLineEndings(&crlf), ); } + +test "normalizeLineEndings leaves binary content untouched" { + // A NUL byte marks the buffer binary; the CRLF must survive so the + // raw-byte fallback hash can still see a CR-only change. + var binary = "PK\x00\x03header\r\npayload".*; + try std.testing.expectEqualStrings("PK\x00\x03header\r\npayload", normalizeLineEndings(&binary)); + + // NUL past the 8000-byte window does not mark the buffer binary. + var big: [8002]u8 = @splat('a'); + big[8000] = 0; + big[0] = '\r'; + big[1] = '\n'; + const normalized = normalizeLineEndings(&big); + try std.testing.expectEqual(@as(usize, 8001), normalized.len); + try std.testing.expectEqual(@as(u8, '\n'), normalized[0]); +} diff --git a/test/integration/link_test.zig b/test/integration/link_test.zig index 8b143bb..bcfb0a4 100644 --- a/test/integration/link_test.zig +++ b/test/integration/link_test.zig @@ -64,7 +64,6 @@ test "link stores repo-relative paths with POSIX separators" { defer allocator.free(lock_content); try helpers.expectContains(lock_content, "doc = \"docs/doc.md\"\n"); try helpers.expectContains(lock_content, "target = \"src/new.ts\"\n"); - try helpers.expectNotContains(lock_content, "\\\\"); } test "link adds symbol binding to drift.lock" {