diff --git a/.claude/skills/drift/SKILL.md b/.claude/skills/drift/SKILL.md index 343a3e4..9e758ea 100644 --- a/.claude/skills/drift/SKILL.md +++ b/.claude/skills/drift/SKILL.md @@ -2,11 +2,12 @@ name: drift description: Drift spec-to-code anchor conventions. Use when editing code that is bound by drift specs, updating specs, working with drift frontmatter, or when drift check reports stale anchors. drift: + origin: github:fiberplane/drift files: - - src/main.zig@sig:d873ec9ee4847ab0 - - src/frontmatter.zig@sig:418dbef4a977ea1d - - src/scanner.zig@sig:161bae32d2c984b8 - - src/vcs.zig@sig:31d5ca6c615ea8dd + - src/main.zig@sig:80171c2f3d2c2f4c + - src/frontmatter.zig@sig:ec04c25b0a6b05b2 + - src/scanner.zig@sig:580c0f12170d4d35 + - src/vcs.zig@sig:1699bd9349c613a6 --- # Drift diff --git a/docs/CLI.md b/docs/CLI.md index 69aa185..d6125b3 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,7 +1,7 @@ --- drift: files: - - src/main.zig@sig:d873ec9ee4847ab0 + - src/main.zig@sig:80171c2f3d2c2f4c --- # CLI Reference @@ -34,9 +34,14 @@ docs/project.md STALE src/core/old-module.ts file not found +vendor/shared-skill.md + SKIP src/main.rs (origin: github:acme/other-repo) + 2 specs stale, 1 ok ``` +Specs with an `origin:` field that doesn't match the current repo are skipped — their anchors reference files in a different repository. + ## drift status Show all specs and their anchors without checking staleness. This includes explicit frontmatter anchors, `` comment anchors, and inline `@./path` references from the spec body. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7cc9a39..ab08bad 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,9 +1,9 @@ --- drift: files: - - src/main.zig@sig:d873ec9ee4847ab0 + - src/main.zig@sig:80171c2f3d2c2f4c - src/symbols.zig@sig:a31cb9bf8bd80d64 - - src/vcs.zig@sig:31d5ca6c615ea8dd + - src/vcs.zig@sig:1699bd9349c613a6 --- # Decisions @@ -92,3 +92,11 @@ Content signatures solve several problems with VCS-based provenance: - The staleness check is a pure function of the file's content, not of VCS state. This makes drift behavior deterministic and easier to reason about. Legacy `@` provenance is still supported — `drift lint` detects the format and routes to the VCS-based comparison path. Migration is incremental: running `drift link ` on any spec rewrites its anchors to `@sig:` format. + +## 10. Origin-qualified anchors + +Specs can declare `origin: github:owner/repo` in their `drift:` frontmatter section. At lint time, drift resolves the current repo's identity from `git remote get-url origin`, normalizes it to `github:owner/repo`, and compares. If a spec's origin doesn't match, its anchors are skipped — they belong to a different repository. + +This solves the problem of specs traveling across repository boundaries. Shared skill files, vendored documentation, and monorepo imports all contain anchors that point at files in the source repo, not the consuming repo. Without origin qualification, `drift lint` would report these as STALE (file not found) every time, creating noise. With it, foreign specs are silently skipped and only local specs are checked. + +Origin is opt-in. Specs without `origin:` are always checked. The normalized format (`github:owner/repo`) is derived from the git remote URL, handling SSH, HTTPS, and SSH URL formats uniformly. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2cb3959..d1c5cd0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,11 +1,11 @@ --- drift: files: - - src/main.zig@sig:d873ec9ee4847ab0 - - src/frontmatter.zig@sig:418dbef4a977ea1d - - src/scanner.zig@sig:161bae32d2c984b8 + - src/main.zig@sig:80171c2f3d2c2f4c + - src/frontmatter.zig@sig:ec04c25b0a6b05b2 + - src/scanner.zig@sig:580c0f12170d4d35 - src/symbols.zig@sig:a31cb9bf8bd80d64 - - src/vcs.zig@sig:31d5ca6c615ea8dd + - src/vcs.zig@sig:1699bd9349c613a6 --- # Design @@ -59,6 +59,12 @@ The `@provenance` suffix is optional. Bare paths still work. Different anchors c `drift link` produces `@sig:` provenance by default. Content signatures are VCS-independent — they encode a fingerprint of the code itself, so staleness detection works without querying git history. Legacy `@` provenance is still supported for backward compatibility. +### Origin-Qualified Anchors + +A spec can declare its origin repository via `origin: github:owner/repo` in the `drift:` section. When `drift lint` runs, it resolves the current repo's identity from `git remote get-url origin` and normalizes it to the same `github:owner/repo` format. If a spec's origin doesn't match the current repo, all its anchors are reported as `SKIP` — they belong to a different repository and can't be checked locally. + +This lets specs travel across repo boundaries (vendored docs, shared skill files, monorepo imports) without producing false STALE reports. Specs without an `origin:` field are always checked — origin qualification is opt-in. + ### Symbol-Level Anchors An anchor like `src/auth/provider.ts#AuthConfig` resolves to a specific AST symbol rather than the whole file. drift parses the file with tree-sitter, finds the symbol's declaration, and hashes a normalized representation of that subtree. Changes elsewhere in the file don't trigger staleness, and formatting-only changes inside the symbol are ignored. diff --git a/src/frontmatter.zig b/src/frontmatter.zig index 813163f..f517972 100644 --- a/src/frontmatter.zig +++ b/src/frontmatter.zig @@ -90,16 +90,32 @@ pub fn anchorFileIdentity(anchor: []const u8) []const u8 { return anchor; } -/// Parse drift frontmatter from file content. Returns anchors list if this is a drift spec, null otherwise. +/// Result of parsing a drift spec: anchors list plus optional origin qualifier. +pub const DriftSpec = struct { + anchors: std.ArrayList([]const u8), + origin: ?[]const u8, +}; + +/// Internal parse result from sub-parsers. +const ParseResult = struct { + anchors: std.ArrayList([]const u8), + origin: ?[]const u8, +}; + +/// Parse drift frontmatter from file content. Returns anchors list and origin if this is a drift spec, null otherwise. /// Checks both YAML frontmatter and HTML comment-based anchors, merging results. -pub fn parseDriftSpec(allocator: std.mem.Allocator, content: []const u8) ?std.ArrayList([]const u8) { +pub fn parseDriftSpec(allocator: std.mem.Allocator, content: []const u8) ?DriftSpec { var anchors: std.ArrayList([]const u8) = .{}; var found_source = false; + var origin: ?[]const u8 = null; // 1. Parse YAML frontmatter anchors if (parseFrontmatterAnchors(allocator, content)) |fm_result| { - var fm_anchors = fm_result; + var fm_anchors = fm_result.anchors; found_source = true; + if (fm_result.origin) |o| { + if (origin == null) origin = o else allocator.free(o); + } for (fm_anchors.items) |b| { anchors.append(allocator, b) catch { allocator.free(b); @@ -110,8 +126,11 @@ pub fn parseDriftSpec(allocator: std.mem.Allocator, content: []const u8) ?std.Ar // 2. Parse HTML comment-based anchors if (parseCommentAnchors(allocator, content)) |comment_result| { - var comment_anchors = comment_result; + var comment_anchors = comment_result.anchors; found_source = true; + if (comment_result.origin) |o| { + if (origin == null) origin = o else allocator.free(o); + } for (comment_anchors.items) |b| { anchors.append(allocator, b) catch { allocator.free(b); @@ -123,14 +142,15 @@ pub fn parseDriftSpec(allocator: std.mem.Allocator, content: []const u8) ?std.Ar if (!found_source) { for (anchors.items) |b| allocator.free(b); anchors.deinit(allocator); + if (origin) |o| allocator.free(o); return null; } - return anchors; + return .{ .anchors = anchors, .origin = origin }; } /// Parse anchors from YAML frontmatter (--- ... --- block). -fn parseFrontmatterAnchors(allocator: std.mem.Allocator, content: []const u8) ?std.ArrayList([]const u8) { +fn parseFrontmatterAnchors(allocator: std.mem.Allocator, content: []const u8) ?ParseResult { if (!std.mem.startsWith(u8, content, "---\n")) return null; const after_open = content[4..]; @@ -139,17 +159,35 @@ fn parseFrontmatterAnchors(allocator: std.mem.Allocator, content: []const u8) ?s const fm = after_open[0..close_offset]; var has_drift = false; + var in_drift_section = false; var in_files_section = false; var anchors: std.ArrayList([]const u8) = .{}; + var origin: ?[]const u8 = null; var lines_iter = std.mem.splitScalar(u8, fm, '\n'); while (lines_iter.next()) |line| { if (std.mem.eql(u8, line, "drift:") or std.mem.startsWith(u8, line, "drift:")) { has_drift = true; + in_drift_section = true; + continue; + } + + // Detect top-level key (not indented) — exits drift section + if (in_drift_section and line.len > 0 and !std.mem.startsWith(u8, line, " ")) { + in_drift_section = false; + in_files_section = false; + } + + if (in_drift_section and std.mem.startsWith(u8, line, " origin: ")) { + const value = line[" origin: ".len..]; + if (value.len > 0) { + origin = allocator.dupe(u8, value) catch null; + } + in_files_section = false; continue; } - if (has_drift and std.mem.startsWith(u8, line, " files:")) { + if (in_drift_section and std.mem.startsWith(u8, line, " files:")) { in_files_section = true; continue; } @@ -172,18 +210,20 @@ fn parseFrontmatterAnchors(allocator: std.mem.Allocator, content: []const u8) ?s if (!has_drift) { for (anchors.items) |b| allocator.free(b); anchors.deinit(allocator); + if (origin) |o| allocator.free(o); return null; } - return anchors; + return .{ .anchors = anchors, .origin = origin }; } /// Parse anchors from `` HTML comment blocks. /// Returns null if no comment-based anchors are found. -fn parseCommentAnchors(allocator: std.mem.Allocator, content: []const u8) ?std.ArrayList([]const u8) { +fn parseCommentAnchors(allocator: std.mem.Allocator, content: []const u8) ?ParseResult { const marker = "" } - if (!found) { + if (!found and origin == null) { for (anchors.items) |b| allocator.free(b); anchors.deinit(allocator); return null; } - return anchors; + return .{ .anchors = anchors, .origin = origin }; } /// Check if content has a `` comment block outside of code contexts. @@ -958,13 +1007,14 @@ test "linkAnchor preserves existing non-drift frontmatter" { try std.testing.expect(std.mem.indexOf(u8, result, " files:") != null); try std.testing.expect(std.mem.indexOf(u8, result, " - src/target.ts") != null); - var anchors = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; + var spec = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 1), anchors.items.len); - try std.testing.expectEqualStrings("src/target.ts", anchors.items[0]); + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("src/target.ts", spec.anchors.items[0]); } test "linkAnchor adds files section when drift exists without files" { @@ -985,13 +1035,14 @@ test "linkAnchor adds files section when drift exists without files" { try std.testing.expect(std.mem.indexOf(u8, result, " - src/target.ts") != null); try std.testing.expect(std.mem.indexOf(u8, result, "title: My Doc\n files:") == null); - var anchors = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; + var spec = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 1), anchors.items.len); - try std.testing.expectEqualStrings("src/target.ts", anchors.items[0]); + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("src/target.ts", spec.anchors.items[0]); } // --- unit tests for comment-based anchors --- @@ -999,43 +1050,46 @@ test "linkAnchor adds files section when drift exists without files" { test "parseDriftSpec parses comment-based anchors" { const allocator = std.testing.allocator; const content = "# My Doc\n\n\n\nSome content.\n"; - var anchors = parseDriftSpec(allocator, content) orelse { + var spec = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 2), anchors.items.len); - try std.testing.expectEqualStrings("src/main.zig", anchors.items[0]); - try std.testing.expectEqualStrings("src/vcs.zig", anchors.items[1]); + try std.testing.expectEqual(@as(usize, 2), spec.anchors.items.len); + try std.testing.expectEqualStrings("src/main.zig", spec.anchors.items[0]); + try std.testing.expectEqualStrings("src/vcs.zig", spec.anchors.items[1]); } test "parseDriftSpec merges frontmatter and comment anchors" { const allocator = std.testing.allocator; const content = "---\ndrift:\n files:\n - src/a.ts\n---\n\n\n"; - var anchors = parseDriftSpec(allocator, content) orelse { + var spec = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 2), anchors.items.len); + try std.testing.expectEqual(@as(usize, 2), spec.anchors.items.len); } test "parseDriftSpec parses comment with provenance" { const allocator = std.testing.allocator; const content = "\n"; - var anchors = parseDriftSpec(allocator, content) orelse { + var spec = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 1), anchors.items.len); - try std.testing.expectEqualStrings("src/main.zig@abc123", anchors.items[0]); + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("src/main.zig@abc123", spec.anchors.items[0]); } test "linkAnchor updates comment-based anchor" { @@ -1107,13 +1161,76 @@ test "parseCommentAnchors skips markers inside fenced code blocks" { \\--> \\``` ; - var anchors = parseDriftSpec(allocator, content) orelse { + var spec = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { - for (anchors.items) |b| allocator.free(b); - anchors.deinit(allocator); + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); + } + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("src/real.zig", spec.anchors.items[0]); +} + +// --- unit tests for origin parsing --- + +test "parseDriftSpec parses origin from YAML frontmatter" { + const allocator = std.testing.allocator; + const content = "---\ndrift:\n origin: github:owner/repo\n files:\n - src/main.zig\n---\n# Spec\n"; + var spec = parseDriftSpec(allocator, content) orelse { + return error.TestUnexpectedResult; + }; + defer { + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); + } + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("github:owner/repo", spec.origin.?); +} + +test "parseDriftSpec returns null origin when not present" { + const allocator = std.testing.allocator; + const content = "---\ndrift:\n files:\n - src/main.zig\n---\n# Spec\n"; + var spec = parseDriftSpec(allocator, content) orelse { + return error.TestUnexpectedResult; + }; + defer { + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); + } + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expect(spec.origin == null); +} + +test "parseDriftSpec parses origin from comment-based anchors" { + const allocator = std.testing.allocator; + const content = "# Doc\n\n\n"; + var spec = parseDriftSpec(allocator, content) orelse { + return error.TestUnexpectedResult; + }; + defer { + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); + } + try std.testing.expectEqual(@as(usize, 1), spec.anchors.items.len); + try std.testing.expectEqualStrings("github:acme/lib", spec.origin.?); +} + +test "parseDriftSpec origin before files in frontmatter" { + const allocator = std.testing.allocator; + const content = "---\ndrift:\n origin: github:test/proj\n files:\n - src/a.ts\n - src/b.ts\n---\n"; + var spec = parseDriftSpec(allocator, content) orelse { + return error.TestUnexpectedResult; + }; + defer { + for (spec.anchors.items) |b| allocator.free(b); + spec.anchors.deinit(allocator); + if (spec.origin) |o| allocator.free(o); } - try std.testing.expectEqual(@as(usize, 1), anchors.items.len); - try std.testing.expectEqualStrings("src/real.zig", anchors.items[0]); + try std.testing.expectEqual(@as(usize, 2), spec.anchors.items.len); + try std.testing.expectEqualStrings("github:test/proj", spec.origin.?); } diff --git a/src/main.zig b/src/main.zig index 30cc90f..4eb9a97 100644 --- a/src/main.zig +++ b/src/main.zig @@ -170,6 +170,10 @@ fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *st const detected_vcs = vcs.detectVcs(); var has_issues = false; + // Get repo identity once for origin-qualified anchor filtering + const repo_identity = vcs.getRepoIdentity(allocator, cwd_path); + defer if (repo_identity) |ri| allocator.free(ri); + for (specs.items) |spec| { stdout_w.print("{s}\n", .{spec.path}) catch {}; @@ -178,6 +182,17 @@ fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *st continue; } + // Skip specs with a foreign origin + if (spec.origin) |origin| { + const is_local = if (repo_identity) |ri| std.mem.eql(u8, origin, ri) else false; + if (!is_local) { + for (spec.anchors.items) |anchor| { + stdout_w.print(" SKIP {s} (origin: {s})\n", .{ anchor, origin }) catch {}; + } + continue; + } + } + // Get last commit/change that touched the spec file const spec_commit = vcs.getLastCommit(allocator, cwd_path, spec.path, detected_vcs) catch |err| { stderr_w.print("vcs error for {s}: {s}\n", .{ spec.path, @errorName(err) }) catch {}; @@ -566,6 +581,10 @@ fn writeSpecsText(w: *std.io.Writer, specs: []const Spec) void { if (spec.anchors.items.len == 1) "" else "s", }) catch {}; + if (spec.origin) |origin| { + w.print(" origin: {s}\n", .{origin}) catch {}; + } + if (spec.anchors.items.len > 0) { w.print(" files:\n", .{}) catch {}; for (spec.anchors.items) |anchor| { @@ -584,10 +603,18 @@ fn writeSpecsJson(w: *std.io.Writer, specs: []const Spec) void { json_w.beginArray() catch return; for (specs) |spec| { - json_w.write(.{ - .spec = spec.path, - .files = spec.anchors.items, - }) catch return; + if (spec.origin) |origin| { + json_w.write(.{ + .spec = spec.path, + .origin = origin, + .files = spec.anchors.items, + }) catch return; + } else { + json_w.write(.{ + .spec = spec.path, + .files = spec.anchors.items, + }) catch return; + } } json_w.endArray() catch return; w.writeByte('\n') catch {}; @@ -721,17 +748,18 @@ fn runLink(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *st } else { // Blanket mode: drift link // Compute per-anchor content signatures instead of a single VCS change ID - const parsed_anchors = frontmatter.parseDriftSpec(allocator, content); - defer if (parsed_anchors) |*anchors| { - var a = anchors.*; + const parsed_spec = frontmatter.parseDriftSpec(allocator, content); + defer if (parsed_spec) |*ps| { + var a = ps.anchors; for (a.items) |b| allocator.free(b); a.deinit(allocator); + if (ps.origin) |o| allocator.free(o); }; var intermediate: []const u8 = try allocator.dupe(u8, content); - if (parsed_anchors) |anchors| { - for (anchors.items) |existing_anchor| { + if (parsed_spec) |drift_spec| { + for (drift_spec.anchors.items) |existing_anchor| { const identity = frontmatter.anchorFileIdentity(existing_anchor); const hash_pos = std.mem.indexOfScalar(u8, identity, '#'); const anchor_file_path = if (hash_pos) |pos| identity[0..pos] else identity; diff --git a/src/scanner.zig b/src/scanner.zig index 0f019be..da7a9be 100644 --- a/src/scanner.zig +++ b/src/scanner.zig @@ -4,11 +4,13 @@ const frontmatter = @import("frontmatter.zig"); pub const Spec = struct { path: []const u8, anchors: std.ArrayList([]const u8), + origin: ?[]const u8 = null, pub fn deinit(self: *Spec, allocator: std.mem.Allocator) void { allocator.free(self.path); for (self.anchors.items) |b| allocator.free(b); self.anchors.deinit(allocator); + if (self.origin) |o| allocator.free(o); } }; @@ -34,10 +36,11 @@ pub fn findSpecs(allocator: std.mem.Allocator, specs: *std.ArrayList(Spec)) !voi }; defer allocator.free(content); - if (frontmatter.parseDriftSpec(allocator, content)) |anchors| { + if (frontmatter.parseDriftSpec(allocator, content)) |drift_spec| { try specs.append(allocator, .{ .path = file_path, - .anchors = anchors, + .anchors = drift_spec.anchors, + .origin = drift_spec.origin, }); } else { allocator.free(file_path); diff --git a/src/vcs.zig b/src/vcs.zig index bcc907a..4349499 100644 --- a/src/vcs.zig +++ b/src/vcs.zig @@ -208,6 +208,64 @@ pub fn getBlameInfo( } } +/// Normalize a GitHub remote URL to `github:owner/repo` format. +/// Handles SSH (`git@github.com:owner/repo`), HTTPS (`https://github.com/owner/repo`), +/// and SSH URL (`ssh://git@github.com/owner/repo`) formats. +/// Strips `.git` suffix and trailing slashes. Returns null for non-GitHub URLs. +pub fn normalizeGitHubUrl(allocator: std.mem.Allocator, url: []const u8) ?[]const u8 { + const trimmed = std.mem.trimRight(u8, url, " \t\n\r/"); + + // Extract the owner/repo path from the URL + const path = blk: { + // SSH: git@github.com:owner/repo + if (std.mem.startsWith(u8, trimmed, "git@github.com:")) { + break :blk trimmed["git@github.com:".len..]; + } + // HTTPS: https://github.com/owner/repo + if (std.mem.startsWith(u8, trimmed, "https://github.com/")) { + break :blk trimmed["https://github.com/".len..]; + } + // SSH URL: ssh://git@github.com/owner/repo + if (std.mem.startsWith(u8, trimmed, "ssh://git@github.com/")) { + break :blk trimmed["ssh://git@github.com/".len..]; + } + return null; + }; + + // Strip .git suffix + const clean = if (std.mem.endsWith(u8, path, ".git")) + path[0 .. path.len - 4] + else + path; + + if (clean.len == 0) return null; + + return std.fmt.allocPrint(allocator, "github:{s}", .{clean}) catch null; +} + +/// Get the normalized repo identity by querying `git remote get-url origin`. +/// Returns `github:owner/repo` or null if not a GitHub remote. +pub fn getRepoIdentity(allocator: std.mem.Allocator, cwd_path: []const u8) ?[]const u8 { + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "git", "remote", "get-url", "origin" }, + .cwd = cwd_path, + .max_output_bytes = 4096, + }) catch return null; + defer allocator.free(result.stderr); + defer allocator.free(result.stdout); + + switch (result.term) { + .Exited => |code| if (code != 0) return null, + else => return null, + } + + const trimmed = std.mem.trimRight(u8, result.stdout, "\n\r "); + if (trimmed.len == 0) return null; + + return normalizeGitHubUrl(allocator, trimmed); +} + /// Get the current change/commit ID (short form) for auto-provenance. pub fn getCurrentChangeId(allocator: std.mem.Allocator, cwd_path: []const u8, vcs: VcsKind) !?[]const u8 { const result = switch (vcs) { @@ -242,3 +300,51 @@ pub fn getCurrentChangeId(allocator: std.mem.Allocator, cwd_path: []const u8, vc allocator.free(stdout); return id; } + +// --- unit tests --- + +test "normalizeGitHubUrl handles SSH format" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "git@github.com:fiberplane/drift.git") orelse + return error.TestUnexpectedResult; + defer allocator.free(result); + try std.testing.expectEqualStrings("github:fiberplane/drift", result); +} + +test "normalizeGitHubUrl handles HTTPS format" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "https://github.com/fiberplane/drift.git") orelse + return error.TestUnexpectedResult; + defer allocator.free(result); + try std.testing.expectEqualStrings("github:fiberplane/drift", result); +} + +test "normalizeGitHubUrl handles SSH URL format" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "ssh://git@github.com/fiberplane/drift") orelse + return error.TestUnexpectedResult; + defer allocator.free(result); + try std.testing.expectEqualStrings("github:fiberplane/drift", result); +} + +test "normalizeGitHubUrl strips trailing slash" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "https://github.com/owner/repo/") orelse + return error.TestUnexpectedResult; + defer allocator.free(result); + try std.testing.expectEqualStrings("github:owner/repo", result); +} + +test "normalizeGitHubUrl without .git suffix" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "https://github.com/owner/repo") orelse + return error.TestUnexpectedResult; + defer allocator.free(result); + try std.testing.expectEqualStrings("github:owner/repo", result); +} + +test "normalizeGitHubUrl returns null for non-GitHub URL" { + const allocator = std.testing.allocator; + const result = normalizeGitHubUrl(allocator, "https://gitlab.com/owner/repo.git"); + try std.testing.expect(result == null); +}