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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Reference: docs/DESIGN.md, docs/DECISIONS.md, docs/CLI.md, docs/RELEASING.md
- File-is-the-struct pattern (Ghostty convention)
- No `anyerror` in public APIs — explicit error sets
- `zig fmt` enforced
- Property tests use minish with deterministic seed overrides
- All tests use `std.testing.allocator`

## Code Patterns
Expand Down Expand Up @@ -64,5 +65,6 @@ Markdown is a supported language with a two-grammar architecture: block grammar

- `zig build test` runs all tests
- Integration tests in `test/integration/`
- Property tests in `test/property/` use minish; pass `-Dminish-seed=<u64>` to reproduce a run
- All tests use `std.testing.allocator` (auto leak detection)
- Test fixtures per language in `test/fixtures/`
26 changes: 26 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub fn build(b: *std.Build) void {

// Dependencies
const clap_dep = b.dependency("clap", .{});
const minish_dep = b.dependency("minish", .{});

// Build tree-sitter C library from vendor sources
const ts_module = buildTreeSitter(b, target, optimize);
Expand Down Expand Up @@ -80,6 +81,14 @@ pub fn build(b: *std.Build) void {
const test_options = b.addOptions();
test_options.addOption([]const u8, "drift_bin", b.getInstallPath(.bin, "drift"));

// 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
// -Dminish-seed=N to reproduce a specific failing run. Null falls back to
// minish's timestamp-based seed.
const minish_seed_override = b.option(u64, "minish-seed", "Fixed seed for minish property tests");
const minish_seed: ?u64 = minish_seed_override orelse gitHeadSeed(b);
test_options.addOption(?u64, "minish_seed", minish_seed);

// Helpers module for integration tests
const helpers_module = b.createModule(.{
.root_source_file = b.path("test/helpers.zig"),
Expand All @@ -99,6 +108,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "tree_sitter", .module = ts_module },
.{ .name = "helpers", .module = helpers_module },
.{ .name = "payload", .module = payload_module },
.{ .name = "minish", .module = minish_dep.module("minish") },
},
});
linkGrammars(b, test_module);
Expand Down Expand Up @@ -163,6 +173,22 @@ fn buildTreeSitter(
return ts_zig_module;
}

/// First 16 hex chars of the current git HEAD as a u64. Returns null in a
/// detached environment (no .git, no `git` on PATH, shallow clone w/ no HEAD).
/// Used to seed property tests per-commit so each commit walks a different path.
fn gitHeadSeed(b: *std.Build) ?u64 {
var code: u8 = undefined;
const stdout = b.runAllowFail(
&.{ "git", "rev-parse", "HEAD" },
&code,
.ignore,
) catch return null;
defer b.allocator.free(stdout);
const trimmed = std.mem.trim(u8, stdout, " \t\r\n");
if (trimmed.len < 16) return null;
return std.fmt.parseInt(u64, trimmed[0..16], 16) catch null;
}

fn linkGrammars(b: *std.Build, module: *std.Build.Module) void {
// Tree-sitter headers needed by grammar C sources
module.addIncludePath(b.path("vendor/tree-sitter/lib/include"));
Expand Down
4 changes: 4 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,9 @@
.hash = "N-V-__8AAOphUwCl_jXY5BvJ_I-kB6cZuE48ZpMar9Gq2SiD",
.lazy = true,
},
.minish = .{
.url = "https://github.com/CogitatorTech/minish/archive/v0.3.0.tar.gz",
.hash = "minish-0.3.0-SQtSTYI3AgCxWdWbKxS_lvmfbp0wJk29ZW5C9CozJaxm",
},
},
}
3 changes: 2 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Additional modules:

### lockfile.zig

Reads and writes `drift.lock`. The file is line-oriented: each non-blank, non-comment line is a binding in the format `<doc> -> <target> <key:value>...`. Parsing is two splits: `splitSequence(" -> ")` for the doc/rest boundary, then `splitScalar(' ')` for target and trailing key:value pairs. Writing sorts all lines lexically and appends a trailing newline.
Reads and writes `drift.lock`. The file is line-oriented: each non-blank, non-comment line is a binding in the format `<doc> -> <target> <key:value>...`. Parsing is two splits: `splitSequence(" -> ")` for the doc/rest boundary, then `splitScalar(' ')` for target and trailing key:value pairs. Writing canonicalizes each binding before output: metadata fields are sorted by key, then all rendered lines are sorted lexically and written with a trailing newline.

Discovery: walks up from cwd checking for `drift.lock` at each directory. The lockfile's directory becomes the project root for resolving relative paths.

Expand Down Expand Up @@ -221,6 +221,7 @@ Format rules:
- One binding per line: `<doc> -> <target> <key:value>...`
- Sorted lexically by full line content
- Trailing key:value pairs for extensible metadata (`sig:`, `origin:`, future fields)
- Metadata fields are serialized in key order so semantically equivalent bindings produce identical bytes
- Lines starting with `#` are comments, blank lines ignored
- Discovery: walk up from cwd until `drift.lock` is found

Expand Down
8 changes: 4 additions & 4 deletions drift.lock
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
.claude/skills/drift/SKILL.md -> src/main.zig sig:b6166454e541e8a4 origin:github:fiberplane/drift
.claude/skills/drift/SKILL.md -> src/vcs.zig sig:84da70be235ca9d4 origin:github:fiberplane/drift
CLAUDE.md -> build.zig sig:7194b38f39dbadba
.claude/skills/drift/SKILL.md -> src/main.zig origin:github:fiberplane/drift sig:b6166454e541e8a4
.claude/skills/drift/SKILL.md -> src/vcs.zig origin:github:fiberplane/drift sig:84da70be235ca9d4
CLAUDE.md -> build.zig sig:2dccb33f6b790afa
CLAUDE.md -> src/main.zig sig:b6166454e541e8a4
docs/CLI.md -> src/commands/link.zig sig:7bd7f824afc30e0b
docs/CLI.md -> src/commands/lint.zig sig:fe7bd5a687f3e917
docs/CLI.md -> src/commands/refs.zig sig:f623b7774086094e
docs/CLI.md -> src/commands/status.zig sig:eade166d24a20b81
docs/CLI.md -> src/commands/unlink.zig sig:0dbe1ee3315211b5
docs/DESIGN.md -> src/context.zig sig:82d9da38ea486f36
docs/DESIGN.md -> src/lockfile.zig sig:0e4b79237b6e4c8b
docs/DESIGN.md -> src/lockfile.zig sig:6fc5c159297244d1
docs/DESIGN.md -> src/main.zig sig:b6166454e541e8a4
docs/DESIGN.md -> src/symbols.zig sig:bbe250d43609daa1
docs/DESIGN.md -> src/vcs.zig sig:84da70be235ca9d4
Expand Down
83 changes: 80 additions & 3 deletions src/lockfile.zig
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,21 @@ pub fn groupByDoc(allocator: std.mem.Allocator, bindings: []Binding) !std.ArrayL
return docs;
}

fn renderLineToWriter(writer: *std.Io.Writer, binding: Binding) !void {
fn lessThanMetadataByKey(_: void, a: MetadataField, b: MetadataField) bool {
return std.mem.order(u8, a.key, b.key) == .lt;
}

/// Serializes one binding as `<doc_path> -> <target> [<key>:<value> ...]` with
/// metadata sorted by key so the on-disk form is a function of semantic state
/// only, not of `setField` insertion order. Uses `scratch` for a sort buffer.
fn renderLineToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, binding: Binding) !void {
try writer.print("{s} -> {s}", .{ binding.doc_path, binding.target });
for (binding.metadata.items) |field| {
if (binding.metadata.items.len == 0) return;

const sorted = try scratch.dupe(MetadataField, binding.metadata.items);
defer scratch.free(sorted);
std.mem.sort(MetadataField, sorted, {}, lessThanMetadataByKey);
for (sorted) |field| {
try writer.print(" {s}:{s}", .{ field.key, field.value });
}
}
Expand All @@ -205,7 +217,7 @@ pub fn serializeToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, bin
for (bindings) |binding| {
var row: std.Io.Writer.Allocating = .init(scratch);
errdefer row.deinit();
try renderLineToWriter(&row.writer, binding);
try renderLineToWriter(scratch, &row.writer, binding);
try lines.append(scratch, try row.toOwnedSlice());
}

Expand Down Expand Up @@ -359,6 +371,71 @@ test "serialize sorts lines and appends trailing newline" {
);
}

test "serialize emits metadata sorted by key regardless of insertion order" {
const allocator = std.testing.allocator;

const mkBinding = struct {
fn f(alloc: std.mem.Allocator, field_pairs: []const [2][]const u8) !Binding {
var metadata: std.ArrayList(MetadataField) = .empty;
errdefer {
for (metadata.items) |field| {
alloc.free(field.key);
alloc.free(field.value);
}
metadata.deinit(alloc);
}
for (field_pairs) |pair| {
try metadata.append(alloc, .{
.key = try alloc.dupe(u8, pair[0]),
.value = try alloc.dupe(u8, pair[1]),
});
}
return .{
.doc_path = try alloc.dupe(u8, "docs/x.md"),
.target = try alloc.dupe(u8, "src/x.ts"),
.metadata = metadata,
};
}
}.f;

const freeBinding = struct {
fn f(alloc: std.mem.Allocator, b: *Binding) void {
alloc.free(b.doc_path);
alloc.free(b.target);
for (b.metadata.items) |field| {
alloc.free(field.key);
alloc.free(field.value);
}
b.metadata.deinit(alloc);
}
}.f;

var forward = [_]Binding{try mkBinding(allocator, &.{
.{ "sig", "abc" },
.{ "origin", "x" },
.{ "lang", "ts" },
})};
defer freeBinding(allocator, &forward[0]);

var reverse = [_]Binding{try mkBinding(allocator, &.{
.{ "lang", "ts" },
.{ "origin", "x" },
.{ "sig", "abc" },
})};
defer freeBinding(allocator, &reverse[0]);

const out_forward = try serialize(allocator, &forward);
defer allocator.free(out_forward);
const out_reverse = try serialize(allocator, &reverse);
defer allocator.free(out_reverse);

try std.testing.expectEqualStrings(out_forward, out_reverse);
try std.testing.expectEqualStrings(
"docs/x.md -> src/x.ts lang:ts origin:x sig:abc\n",
out_forward,
);
}

test "discover walks up to find drift.lock" {
const allocator = std.testing.allocator;
const io = std.testing.io;
Expand Down
5 changes: 5 additions & 0 deletions test/helpers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const std = @import("std");
const build_options = @import("build_options");
const payload = @import("payload");

/// Seed for minish property tests. Defaults to the first 16 hex chars of the
/// current git HEAD so each commit explores a fresh slice of the state space.
/// Null falls back to minish's timestamp seed. Override with `-Dminish-seed=N`.
pub const minish_seed: ?u64 = build_options.minish_seed;

/// Result returned by runDrift.
pub const ExecResult = struct {
stdout: []const u8,
Expand Down
Loading
Loading