diff --git a/src/explore.zig b/src/explore.zig index 891bb6e..828121e 100644 --- a/src/explore.zig +++ b/src/explore.zig @@ -133,6 +133,9 @@ pub const Explorer = struct { word_index: WordIndex, trigram_index: AnyTrigramIndex, sparse_ngram_index: SparseNgramIndex, + /// Paths indexed with skip_trigram=true (past 15k cap or excluded). + /// Used to restrict the searchContent fallback to only these files. + skip_trigram_files: std.StringHashMap(void), allocator: std.mem.Allocator, word_index_complete: bool = true, word_index_can_load_from_disk: bool = false, @@ -152,6 +155,7 @@ pub const Explorer = struct { .word_index = WordIndex.init(allocator), .trigram_index = .{ .heap = TrigramIndex.init(allocator) }, .sparse_ngram_index = SparseNgramIndex.init(allocator), + .skip_trigram_files = std.StringHashMap(void).init(allocator), .allocator = allocator, }; } @@ -179,6 +183,7 @@ pub const Explorer = struct { self.word_index.deinit(); self.trigram_index.deinit(); self.sparse_ngram_index.deinit(); + self.skip_trigram_files.deinit(); if (self.root_dir) |*d| d.close(); } @@ -270,15 +275,24 @@ pub const Explorer = struct { self.word_index_can_load_from_disk = false; } try self.word_index.indexFile(stable_path, content); + // If trigram indexing fails below, restore word_index to its previous state + // to prevent word_index and trigram_index from diverging. + errdefer if (prior_content) |old| { + self.word_index.indexFile(stable_path, old) catch {}; + } else { + self.word_index.removeFile(stable_path); + }; if (self.word_index_complete) { self.word_index_generation +%= 1; } if (!skip_trigram) { try self.trigram_index.indexFile(stable_path, content); try self.sparse_ngram_index.indexFile(stable_path, content); + _ = self.skip_trigram_files.remove(stable_path); } else { self.trigram_index.removeFile(stable_path); self.sparse_ngram_index.removeFile(stable_path); + try self.skip_trigram_files.put(stable_path, {}); } } @@ -792,10 +806,9 @@ pub fn parseContentForIndexing(allocator: std.mem.Allocator, path: []const u8, c } if (result_list.items.len < max_results) { - var iter = self.outlines.keyIterator(); + var iter = self.skip_trigram_files.keyIterator(); while (iter.next()) |key_ptr| { if (searched.contains(key_ptr.*)) continue; - if (self.trigram_index.containsFile(key_ptr.*)) continue; const ref = self.readContentForSearch(key_ptr.*, allocator) orelse continue; defer ref.deinit(); try searchInContent(key_ptr.*, ref.data, query, allocator, max_results, &result_list); diff --git a/src/index.zig b/src/index.zig index 6f50d3b..44824d4 100644 --- a/src/index.zig +++ b/src/index.zig @@ -497,12 +497,17 @@ pub const PostingList = struct { } pub fn removeDocId(self: *PostingList, doc_id: u32) void { - var i: usize = 0; - while (i < self.items.items.len) { - if (self.items.items[i].doc_id == doc_id) { - _ = self.items.orderedRemove(i); + var lo: usize = 0; + var hi: usize = self.items.items.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (self.items.items[mid].doc_id < doc_id) { + lo = mid + 1; + } else if (self.items.items[mid].doc_id > doc_id) { + hi = mid; } else { - i += 1; + _ = self.items.orderedRemove(mid); + return; } } } @@ -515,8 +520,10 @@ pub const TrigramIndex = struct { file_trigrams: std.StringHashMap(std.ArrayList(Trigram)), /// path → doc_id mapping path_to_id: std.StringHashMap(u32), - /// doc_id → path mapping + /// doc_id → path mapping (may contain "" sentinels for freed slots) id_to_path: std.ArrayList([]const u8), + /// freed doc_id slots available for reuse by getOrCreateDocId + free_ids: std.ArrayList(u32), allocator: std.mem.Allocator, /// When true, deinit frees the path keys in file_trigrams (set by readFromDisk). owns_paths: bool = false, @@ -527,6 +534,7 @@ pub const TrigramIndex = struct { .file_trigrams = std.StringHashMap(std.ArrayList(Trigram)).init(allocator), .path_to_id = std.StringHashMap(u32).init(allocator), .id_to_path = .{}, + .free_ids = .{}, .allocator = allocator, }; } @@ -547,12 +555,20 @@ pub const TrigramIndex = struct { self.path_to_id.deinit(); self.id_to_path.deinit(self.allocator); + self.free_ids.deinit(self.allocator); } fn getOrCreateDocId(self: *TrigramIndex, path: []const u8) !u32 { if (self.path_to_id.get(path)) |id| return id; - const id: u32 = @intCast(self.id_to_path.items.len); - try self.id_to_path.append(self.allocator, path); + const id: u32 = if (self.free_ids.items.len > 0) blk: { + const freed: u32 = self.free_ids.pop() orelse unreachable; + self.id_to_path.items[@as(usize, freed)] = path; + break :blk freed; + } else blk: { + const new_id: u32 = @intCast(self.id_to_path.items.len); + try self.id_to_path.append(self.allocator, path); + break :blk new_id; + }; try self.path_to_id.put(path, id); return id; } @@ -564,6 +580,11 @@ pub const TrigramIndex = struct { _ = self.file_trigrams.remove(path); return; }; + // Always clean path_to_id first, regardless of whether file_trigrams has an entry. + _ = self.path_to_id.remove(path); + // Free the doc_id slot for reuse on next indexFile call. + self.free_ids.append(self.allocator, doc_id) catch {}; + self.id_to_path.items[doc_id] = ""; const trigrams = self.file_trigrams.getPtr(path) orelse return; for (trigrams.items) |tri| { if (self.index.getPtr(tri)) |posting_list| { @@ -576,13 +597,17 @@ pub const TrigramIndex = struct { } trigrams.deinit(self.allocator); _ = self.file_trigrams.remove(path); - _ = self.path_to_id.remove(path); } pub fn indexFile(self: *TrigramIndex, path: []const u8, content: []const u8) !void { + const id_count_before = self.id_to_path.items.len; self.removeFile(path); const doc_id = try self.getOrCreateDocId(path); + // If id_to_path grew, this is a brand-new file (doc_id == max), so append + // maintains sorted PostingList order. If it did not grow, a freed slot was + // reused and we must use sorted insert to preserve the invariant. + const is_new_doc = self.id_to_path.items.len > id_count_before; // Phase 1: accumulate masks locally per trigram (no global index writes) var local = std.AutoHashMap(Trigram, PostingMask).init(self.allocator); @@ -630,12 +655,19 @@ pub const TrigramIndex = struct { if (!idx_gop.found_existing) { idx_gop.value_ptr.* = .{ .path_to_id = &self.path_to_id }; } - // Single append (not sorted insert) since doc_id is monotonically increasing - try idx_gop.value_ptr.items.append(self.allocator, .{ - .doc_id = doc_id, - .next_mask = mask.next_mask, - .loc_mask = mask.loc_mask, - }); + if (is_new_doc) { + // New doc_id is always max: append maintains sorted PostingList order. + try idx_gop.value_ptr.items.append(self.allocator, .{ + .doc_id = doc_id, + .next_mask = mask.next_mask, + .loc_mask = mask.loc_mask, + }); + } else { + // Reused doc_id: sorted insert to maintain PostingList binary-search invariant. + const posting = try idx_gop.value_ptr.getOrAddPosting(self.allocator, doc_id); + posting.next_mask = mask.next_mask; + posting.loc_mask = mask.loc_mask; + } try tri_list.append(self.allocator, tri); } @@ -1649,7 +1681,10 @@ pub const AnyTrigramIndex = union(enum) { result.ensureTotalCapacity(allocator, merged.count()) catch break :blk null; var it = merged.keyIterator(); while (it.next()) |k| result.appendAssumeCapacity(k.*); - break :blk result.toOwnedSlice(allocator) catch null; + break :blk result.toOwnedSlice(allocator) catch { + result.deinit(allocator); + break :blk null; + }; }, }; } @@ -1682,7 +1717,10 @@ pub const AnyTrigramIndex = union(enum) { result.ensureTotalCapacity(allocator, merged.count()) catch break :blk null; var it = merged.keyIterator(); while (it.next()) |k| result.appendAssumeCapacity(k.*); - break :blk result.toOwnedSlice(allocator) catch null; + break :blk result.toOwnedSlice(allocator) catch { + result.deinit(allocator); + break :blk null; + }; }, }; } diff --git a/src/lib.zig b/src/lib.zig index 28ff3f7..a017a7b 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -54,4 +54,5 @@ pub const applyEdit = @import("edit.zig").applyEdit; pub const watcher = @import("watcher.zig"); pub const mcp = @import("mcp.zig"); +pub const snapshot = @import("snapshot.zig"); pub const snapshot_json = @import("snapshot_json.zig"); diff --git a/src/main.zig b/src/main.zig index 00b6630..3f8c364 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,8 @@ const snapshot_mod = @import("snapshot.zig"); const telemetry = @import("telemetry.zig"); const root_policy = @import("root_policy.zig"); const nuke_mod = @import("nuke.zig"); +const update_mod = @import("update.zig"); +const release_info = @import("release_info.zig"); /// Thin wrapper: format + write to a File via allocator. const Out = struct { @@ -100,7 +102,7 @@ fn mainImpl() !void { // Handle --version early (no root needed) if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "version")) { - out.p("codedb 0.2.56\n", .{}); + out.p("codedb {s}\n", .{release_info.semver}); return; } @@ -110,48 +112,9 @@ fn mainImpl() !void { return; } - // Handle update command — direct binary download from GitHub releases. - // The CDN install script has issues with set -euo pipefail on macOS, - // so we download the binary directly and replace in-place. + // Handle update command early — before root resolution so it works from anywhere. if (std.mem.eql(u8, cmd, "update")) { - out.p("updating codedb...\n", .{}); - var child = std.process.Child.init( - &.{ - "/bin/bash", "-c", - \\set -e - \\PLATFORM="$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)" - \\case "$PLATFORM" in - \\ darwin-arm64) BIN="codedb-darwin-arm64" ;; - \\ darwin-x86_64) BIN="codedb-darwin-x86_64" ;; - \\ linux-x86_64) BIN="codedb-linux-x86_64" ;; - \\ linux-aarch64) BIN="codedb-linux-aarch64" ;; - \\ *) echo "unsupported platform: $PLATFORM" >&2; exit 1 ;; - \\esac - \\VERSION=$(curl -fsSL https://api.github.com/repos/justrach/codedb/releases/latest 2>/dev/null | grep -oE '"tag_name"\s*:\s*"v[^"]*"' | cut -d'"' -f4 | sed 's/^v//') - \\if [ -z "$VERSION" ]; then - \\ VERSION=$(curl -fsSL https://codedb.codegraff.com/latest.json | grep -oE '"version"\s*:\s*"[^"]*"' | cut -d'"' -f4) - \\fi - \\if [ -z "$VERSION" ]; then - \\ echo "failed to determine latest version" >&2 - \\ exit 1 - \\fi - \\echo " latest: v${VERSION}" - \\TMP=$(mktemp) - \\curl -fsSL "https://github.com/justrach/codedb/releases/download/v${VERSION}/${BIN}" -o "$TMP" - \\SELF=$(which codedb 2>/dev/null || echo "$HOME/bin/codedb") - \\chmod +x "$TMP" - \\mv -f "$TMP" "$SELF" - \\echo " updated: $($SELF --version)" - }, - allocator, - ); - child.stdin_behavior = .Inherit; - child.stdout_behavior = .Inherit; - child.stderr_behavior = .Inherit; - _ = child.spawnAndWait() catch { - out.p("update failed\n", .{}); - std.process.exit(1); - }; + update_mod.run(stdout, s, allocator); return; } @@ -732,10 +695,6 @@ fn printUsage(out: Out, s: sty.Style) void { \\ {s}find{s} {s}{s} find where a symbol is defined \\ {s}search{s} {s}{s} full-text search (trigram, case-insensitive) \\ {s}word{s} {s}{s} exact word lookup via inverted index - \\ {s}hot{s} recently modified files - \\ {s}serve{s} HTTP daemon on :7719 - \\ {s}mcp{s} JSON-RPC/MCP server over stdio - \\ {s}nuke{s} uninstall codedb, clear caches, and deregister integrations \\ , .{ s.bold, s.reset, @@ -750,6 +709,16 @@ fn printUsage(out: Out, s: sty.Style) void { s.dim, s.reset, s.cyan, s.reset, s.dim, s.reset, + }); + out.p( + \\ {s}hot{s} recently modified files + \\ {s}serve{s} HTTP daemon on :7719 + \\ {s}mcp{s} JSON-RPC/MCP server over stdio + \\ {s}update{s} self-update to the latest verified release + \\ {s}nuke{s} uninstall codedb, clear caches, and deregister integrations + \\ + , .{ + s.cyan, s.reset, s.cyan, s.reset, s.cyan, s.reset, s.cyan, s.reset, diff --git a/src/mcp.zig b/src/mcp.zig index 566c860..6047b31 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -19,6 +19,7 @@ const snapshot_mod = @import("snapshot.zig"); const telemetry_mod = @import("telemetry.zig"); const git_mod = @import("git.zig"); const root_policy = @import("root_policy.zig"); +const release_info = @import("release_info.zig"); // ── Project cache ──────────────────────────────────────────────────────────── const ProjectCtx = struct { @@ -493,9 +494,11 @@ fn handleInitialize(s: *Session, root: *const std.json.ObjectMap, id: ?std.json. s.client_name = name; } } - writeResult(s.alloc, s.stdout, id, - \\{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"codedb","version":"0.2.56"}} - ); + const init_result = std.fmt.allocPrint(s.alloc, + \\{{"protocolVersion":"2025-06-18","capabilities":{{"tools":{{"listChanged":false}}}},"serverInfo":{{"name":"codedb","version":"{s}"}}}} + , .{release_info.semver}) catch return; + defer s.alloc.free(init_result); + writeResult(s.alloc, s.stdout, id, init_result); } fn requestRoots(s: *Session) void { diff --git a/src/nuke.zig b/src/nuke.zig index fa678ab..8e8e748 100644 --- a/src/nuke.zig +++ b/src/nuke.zig @@ -27,11 +27,13 @@ pub fn run(stdout: std.fs.File, s: sty.Style, allocator: std.mem.Allocator) void std.process.exit(1); }; defer allocator.free(home); + const self_exe = std.fs.selfExePathAlloc(allocator) catch null; + defer if (self_exe) |path| allocator.free(path); var stats = NukeStats{}; const self_pid = std.c.getpid(); - stats.killed_processes = killOtherCodedbProcesses(allocator, self_pid); + stats.killed_processes = killOtherCodedbProcesses(allocator, self_pid, self_exe); stats.integrations_removed = deregisterInstalledIntegrations(allocator, home); stats.snapshots_removed = removeRegisteredSnapshots(allocator, home); @@ -39,7 +41,7 @@ pub fn run(stdout: std.fs.File, s: sty.Style, allocator: std.mem.Allocator) void stats.snapshots_removed += 1; } - stats.binaries_removed = removeInstalledBinaries(allocator, home); + stats.binaries_removed = removeInstalledBinaries(home, self_exe); const codedb_dir = std.fmt.allocPrint(allocator, "{s}/.codedb", .{home}) catch { out.p("{s}\xe2\x9c\x97{s} failed to allocate uninstall paths\n", .{ s.red, s.reset }); @@ -66,7 +68,8 @@ pub fn run(stdout: std.fs.File, s: sty.Style, allocator: std.mem.Allocator) void out.p("\n to reinstall: {s}curl -fsSL https://codedb.codegraff.com/install.sh | bash{s}\n", .{ s.cyan, s.reset }); } -fn killOtherCodedbProcesses(allocator: std.mem.Allocator, self_pid: std.c.pid_t) usize { +fn killOtherCodedbProcesses(allocator: std.mem.Allocator, self_pid: std.c.pid_t, self_exe: ?[]const u8) usize { + const executable_path = self_exe orelse return 0; var killed: usize = 0; var pid_buf: [32]u8 = undefined; const self_pid_str = std.fmt.bufPrint(&pid_buf, "{d}", .{self_pid}) catch "0"; @@ -84,6 +87,9 @@ fn killOtherCodedbProcesses(allocator: std.mem.Allocator, self_pid: std.c.pid_t) const trimmed = std.mem.trim(u8, pid_line, " \t\r\n"); if (trimmed.len == 0) continue; if (std.mem.eql(u8, trimmed, self_pid_str)) continue; + const command_line = readProcessCommandLine(allocator, trimmed) orelse continue; + defer allocator.free(command_line); + if (!commandTargetsBinary(command_line, executable_path)) continue; const kill_result = std.process.Child.run(.{ .allocator = allocator, .argv = &.{ "kill", trimmed }, @@ -99,6 +105,43 @@ fn killOtherCodedbProcesses(allocator: std.mem.Allocator, self_pid: std.c.pid_t) return killed; } +fn readProcessCommandLine(allocator: std.mem.Allocator, pid: []const u8) ?[]u8 { + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "ps", "-p", pid, "-o", "args=" }, + .max_output_bytes = 4096, + }) catch return null; + defer allocator.free(result.stderr); + + if (result.term != .Exited or result.term.Exited != 0) { + allocator.free(result.stdout); + return null; + } + + return result.stdout; +} + +pub fn commandTargetsBinary(command_line: []const u8, executable_path: []const u8) bool { + if (std.mem.indexOf(u8, command_line, executable_path) != null) return true; + + const command_exe = commandExecutablePath(command_line) orelse return false; + return std.mem.eql(u8, normalizeExecutablePath(command_exe), normalizeExecutablePath(executable_path)); +} + +fn commandExecutablePath(command_line: []const u8) ?[]const u8 { + const trimmed = std.mem.trim(u8, command_line, " \t\r\n"); + if (trimmed.len == 0) return null; + const exe_end = std.mem.indexOfScalar(u8, trimmed, ' ') orelse trimmed.len; + return trimmed[0..exe_end]; +} + +fn normalizeExecutablePath(path: []const u8) []const u8 { + if (std.mem.startsWith(u8, path, "/private/")) { + return path["/private".len..]; + } + return path; +} + fn removeRegisteredSnapshots(allocator: std.mem.Allocator, home: []const u8) usize { var removed: usize = 0; const projects_dir = std.fmt.allocPrint(allocator, "{s}/.codedb/projects", .{home}) catch return 0; @@ -148,24 +191,21 @@ fn deregisterInstalledIntegrations(allocator: std.mem.Allocator, home: []const u return removed; } -fn removeInstalledBinaries(allocator: std.mem.Allocator, home: []const u8) usize { +fn removeInstalledBinaries(home: []const u8, self_exe: ?[]const u8) usize { var removed: usize = 0; - const self_exe = std.fs.selfExePathAlloc(allocator) catch null; - defer if (self_exe) |path| allocator.free(path); - if (self_exe) |path| { if (deleteFileIfExists(path)) removed += 1; } - const home_bin = std.fmt.allocPrint(allocator, "{s}/bin/codedb", .{home}) catch return removed; - defer allocator.free(home_bin); + var home_bin_buf: [std.fs.max_path_bytes]u8 = undefined; + const home_bin = std.fmt.bufPrint(&home_bin_buf, "{s}/bin/codedb", .{home}) catch return removed; if (self_exe == null or !std.mem.eql(u8, self_exe.?, home_bin)) { if (deleteFileIfExists(home_bin)) removed += 1; } - const home_bin_exe = std.fmt.allocPrint(allocator, "{s}/bin/codedb.exe", .{home}) catch return removed; - defer allocator.free(home_bin_exe); + var home_bin_exe_buf: [std.fs.max_path_bytes]u8 = undefined; + const home_bin_exe = std.fmt.bufPrint(&home_bin_exe_buf, "{s}/bin/codedb.exe", .{home}) catch return removed; if ((self_exe == null or !std.mem.eql(u8, self_exe.?, home_bin_exe)) and !std.mem.eql(u8, home_bin, home_bin_exe)) { if (deleteFileIfExists(home_bin_exe)) removed += 1; } @@ -195,7 +235,7 @@ pub fn deregisterJsonIntegrationFile(allocator: std.mem.Allocator, path: []const const rewritten = try removeJsonMcpServerEntry(allocator, content, "codedb") orelse return false; defer allocator.free(rewritten); - try rewriteConfigFile(path, rewritten); + try rewriteConfigFile(allocator, path, rewritten); return true; } @@ -205,11 +245,11 @@ pub fn deregisterCodexIntegrationFile(allocator: std.mem.Allocator, path: []cons const rewritten = try removeCodexMcpServerBlock(allocator, content, "codedb") orelse return false; defer allocator.free(rewritten); - try rewriteConfigFile(path, rewritten); + try rewriteConfigFile(allocator, path, rewritten); return true; } -fn rewriteConfigFile(path: []const u8, content: []const u8) !void { +fn rewriteConfigFile(allocator: std.mem.Allocator, path: []const u8, content: []const u8) !void { if (std.mem.trim(u8, content, " \t\r\n").len == 0) { std.fs.cwd().deleteFile(path) catch |err| switch (err) { error.FileNotFound => {}, @@ -218,9 +258,16 @@ fn rewriteConfigFile(path: []const u8, content: []const u8) !void { return; } - const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); - defer file.close(); - try file.writeAll(content); + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path}); + defer allocator.free(tmp_path); + errdefer std.fs.cwd().deleteFile(tmp_path) catch {}; + { + const file = try std.fs.cwd().createFile(tmp_path, .{}); + defer file.close(); + try file.writeAll(content); + try file.sync(); + } + try std.fs.rename(std.fs.cwd(), tmp_path, std.fs.cwd(), path); } pub fn removeJsonMcpServerEntry(allocator: std.mem.Allocator, content: []const u8, server_name: []const u8) !?[]u8 { diff --git a/src/release_info.zig b/src/release_info.zig new file mode 100644 index 0000000..e856dd2 --- /dev/null +++ b/src/release_info.zig @@ -0,0 +1 @@ +pub const semver = "0.2.56"; diff --git a/src/snapshot.zig b/src/snapshot.zig index efaa3a4..58ea81d 100644 --- a/src/snapshot.zig +++ b/src/snapshot.zig @@ -298,10 +298,7 @@ pub fn writeSnapshot( } /// Read section table from a `.codedb` file. -pub fn readSections(path: []const u8, allocator: std.mem.Allocator) !?std.AutoHashMap(u32, SectionEntry) { - const file = std.fs.cwd().openFile(path, .{}) catch return null; - defer file.close(); - +fn readSectionsFromFile(file: std.fs.File, allocator: std.mem.Allocator) !?std.AutoHashMap(u32, SectionEntry) { var magic_buf: [4]u8 = undefined; const n = file.readAll(&magic_buf) catch return null; if (n != 4 or !std.mem.eql(u8, &magic_buf, &MAGIC)) return null; @@ -332,15 +329,22 @@ pub fn readSections(path: []const u8, allocator: std.mem.Allocator) !?std.AutoHa return result; } +pub fn readSections(path: []const u8, allocator: std.mem.Allocator) !?std.AutoHashMap(u32, SectionEntry) { + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + return readSectionsFromFile(file, allocator); +} + /// Read a section's raw bytes from a `.codedb` file. pub fn readSectionBytes(path: []const u8, section_id: SectionId, allocator: std.mem.Allocator) !?[]u8 { - var sections = try readSections(path, allocator) orelse return null; + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + + var sections = try readSectionsFromFile(file, allocator) orelse return null; defer sections.deinit(); const entry = sections.get(@intFromEnum(section_id)) orelse return null; if (entry.length > 256 * 1024 * 1024) return null; // sanity cap: 256MB - const file = try std.fs.cwd().openFile(path, .{}); - defer file.close(); // Validate section fits within file const stat = try compat.fileStat(file); @@ -349,8 +353,8 @@ pub fn readSectionBytes(path: []const u8, section_id: SectionId, allocator: std. try file.seekTo(entry.offset); const buf = try allocator.alloc(u8, @intCast(entry.length)); errdefer allocator.free(buf); - const n = try file.readAll(buf); - if (n != buf.len) { + const nr = try file.readAll(buf); + if (nr != buf.len) { allocator.free(buf); return null; } @@ -626,7 +630,7 @@ fn loadOutlineStateMap(snapshot_path: []const u8, allocator: std.mem.Allocator) const symbol_count = try readSectionInt(u32, bytes, &cursor); for (0..symbol_count) |_| { - const name = try readSectionString(bytes, &cursor, allocator, 4096); + const name = try readSectionString(bytes, &cursor, allocator, std.math.maxInt(u16)); if (name.len == 0) return error.InvalidData; errdefer allocator.free(name); @@ -637,7 +641,7 @@ fn loadOutlineStateMap(snapshot_path: []const u8, allocator: std.mem.Allocator) const has_detail = try readSectionByte(bytes, &cursor); const detail = switch (has_detail) { 0 => null, - 1 => try readSectionString(bytes, &cursor, allocator, 4096), + 1 => try readSectionString(bytes, &cursor, allocator, std.math.maxInt(u16)), else => return error.InvalidData, }; errdefer if (detail) |d| allocator.free(d); @@ -700,7 +704,7 @@ fn loadSnapshotFast( store: *Store, allocator: std.mem.Allocator, ) !bool { - var outline_states = try loadOutlineStateMap(snapshot_path, allocator); + var outline_states = loadOutlineStateMap(snapshot_path, allocator) catch std.StringHashMap(FileOutline).init(allocator); defer deinitOutlineStateMap(&outline_states, allocator); var sections = (try readSections(snapshot_path, allocator)) orelse return false; diff --git a/src/store.zig b/src/store.zig index 75cc6bf..1e9d1e9 100644 --- a/src/store.zig +++ b/src/store.zig @@ -16,7 +16,7 @@ pub const ChangeEntry = struct { pub const Store = struct { files: std.StringHashMap(FileVersions), - seq: std.atomic.Value(u64), + seq: u64, allocator: std.mem.Allocator, mu: std.Thread.Mutex = .{}, data_log: ?std.fs.File = null, @@ -25,7 +25,7 @@ pub const Store = struct { pub fn init(allocator: std.mem.Allocator) Store { return .{ .files = std.StringHashMap(FileVersions).init(allocator), - .seq = std.atomic.Value(u64).init(0), + .seq = 0, .allocator = allocator, }; } @@ -66,7 +66,8 @@ pub const Store = struct { self.mu.lock(); defer self.mu.unlock(); - const next_seq = self.seq.fetchAdd(1, .monotonic) + 1; + self.seq += 1; + const next_seq = self.seq; const entry = try self.files.getOrPut(path); if (!entry.found_existing) { @@ -187,7 +188,9 @@ pub const Store = struct { } pub fn currentSeq(self: *Store) u64 { - return self.seq.load(.acquire); + self.mu.lock(); + defer self.mu.unlock(); + return self.seq; } pub fn listFiles(self: *Store) ![][]const u8 { diff --git a/src/tests.zig b/src/tests.zig index 161903d..b5cc705 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -32,6 +32,7 @@ const SymbolKind = explore.SymbolKind; const mcp_mod = @import("mcp.zig"); const main_mod = @import("main.zig"); const nuke_mod = @import("nuke.zig"); +const update_mod = @import("update.zig"); const snapshot_mod = @import("snapshot.zig"); const telemetry_mod = @import("telemetry.zig"); // ── Store tests ───────────────────────────────────────────── @@ -4756,6 +4757,10 @@ test "issue-150: --help prints usage" { try testing.expect(result.term.Exited == 0); try testing.expect(std.mem.indexOf(u8, result.stdout, "usage:") != null or std.mem.indexOf(u8, result.stderr, "usage:") != null); + try testing.expect(std.mem.indexOf(u8, result.stdout, "update") != null or + std.mem.indexOf(u8, result.stderr, "update") != null); + try testing.expect(std.mem.indexOf(u8, result.stdout, "nuke") != null or + std.mem.indexOf(u8, result.stderr, "nuke") != null); } test "issue-150: -h prints usage" { @@ -4784,6 +4789,50 @@ test "issue-150: -h prints usage" { std.mem.indexOf(u8, result.stderr, "usage:") != null); } +test "update: compareVersions orders semantic versions" { + try testing.expect(try update_mod.compareVersions("0.2.55", "0.2.56") == .lt); + try testing.expect(try update_mod.compareVersions("0.2.56", "0.2.56") == .eq); + try testing.expect(try update_mod.compareVersions("v0.2.57", "0.2.56") == .gt); + try testing.expect(try update_mod.compareVersions("0.2.56", "0.2.56.0") == .eq); +} + +test "update: checksumForBinary parses release manifest" { + const manifest = + \\7be38140d090b2e23723c8cde02be150171c818daa16b18c520b44cc1e078add codedb-darwin-arm64 + \\76bc7b81bc9fd211aa2c1ac59d1d26e8c80bc211ab560de2dc998ea9e04ec471 codedb-darwin-x86_64 + \\aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa *codedb-linux-arm64 + ; + + try testing.expectEqualStrings( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + update_mod.checksumForBinary(manifest, "codedb-linux-arm64") orelse return error.TestUnexpectedResult, + ); + try testing.expect(update_mod.checksumForBinary(manifest, "codedb-linux-x86_64") == null); +} + +test "update: asset names match published release naming" { + try testing.expectEqualStrings("codedb-darwin-arm64", update_mod.assetNameForTarget(.macos, .aarch64).?); + try testing.expectEqualStrings("codedb-darwin-x86_64", update_mod.assetNameForTarget(.macos, .x86_64).?); + try testing.expectEqualStrings("codedb-linux-arm64", update_mod.assetNameForTarget(.linux, .aarch64).?); + try testing.expectEqualStrings("codedb-linux-x86_64", update_mod.assetNameForTarget(.linux, .x86_64).?); + try testing.expect(update_mod.assetNameForTarget(.windows, .x86_64) == null); +} + +test "nuke: commandTargetsBinary only matches the current install path" { + try testing.expect(nuke_mod.commandTargetsBinary( + "/tmp/codedb-test/bin/codedb serve", + "/tmp/codedb-test/bin/codedb", + )); + try testing.expect(nuke_mod.commandTargetsBinary( + "/var/folders/example/codedb serve", + "/private/var/folders/example/codedb", + )); + try testing.expect(!nuke_mod.commandTargetsBinary( + "/Users/rachpradhan/bin/codedb --mcp", + "/tmp/codedb-test/bin/codedb", + )); +} + test "nuke: removeJsonMcpServerEntry drops only codedb integration" { const input = \\{ @@ -5610,3 +5659,109 @@ test "issue-179: Python docstring with text does not leak symbols" { try testing.expect(found_real); try testing.expect(!found_fake); } + +// ── New bug / perf regression tests ───────────────────────────────────── + +test "issue-246: TrigramIndex.removeFile cleans stale path_to_id left by failed indexFile" { + // Reproduces the corrupted state an OOM mid-way through indexFile leaves: + // removeFile cleared file_trigrams, getOrCreateDocId wrote to path_to_id, + // then an allocation failure meant file_trigrams.put never completed. + // Fix: removeFile must purge path_to_id even when file_trigrams has no entry. + var idx = TrigramIndex.init(testing.allocator); + defer idx.deinit(); + + // Plant the invariant-violating state OOM would leave behind. + try idx.path_to_id.put("ghost.zig", 0); + try idx.id_to_path.append(testing.allocator, "ghost.zig"); + // file_trigrams intentionally has NO entry for "ghost.zig". + + idx.removeFile("ghost.zig"); + + // Currently FAILS: removeFile returns early at the second file_trigrams.getPtr + // check, leaving path_to_id permanently dirty. + try testing.expectEqual(@as(usize, 0), idx.path_to_id.count()); +} + +test "issue-247: TrigramIndex.id_to_path does not grow on re-index of same file" { + // removeFile removes path_to_id[path] but leaves the id_to_path slot intact. + // getOrCreateDocId then appends a new slot since path_to_id misses. + // After N re-indexes id_to_path.items.len must equal the number of *unique* files. + var idx = TrigramIndex.init(testing.allocator); + defer idx.deinit(); + + const src = "fn alpha() void {} fn beta() void {} const X = 1;"; + var i: usize = 0; + while (i < 5) : (i += 1) { + try idx.indexFile("f.zig", src); + } + + // Currently FAILS: id_to_path.items.len == 5 (grows by 1 per re-index). + try testing.expectEqual(@as(usize, 1), idx.id_to_path.items.len); +} + +test "issue-227: TrigramIndex.id_to_path stays bounded across many files re-indexed" { + // Broader regression: ensure re-indexing multiple distinct files also doesn't + // accumulate dead id_to_path slots. + var idx = TrigramIndex.init(testing.allocator); + defer idx.deinit(); + + const files = [_][]const u8{ "a.zig", "b.zig", "c.zig" }; + var round: usize = 0; + while (round < 4) : (round += 1) { + for (files) |f| try idx.indexFile(f, "fn foo() void {}"); + } + + // 3 unique files × 4 rounds = 12 slots currently; fix should keep it at 3. + try testing.expectEqual(@as(usize, files.len), idx.id_to_path.items.len); +} + +test "issue-248: PostingList.removeDocId removes target and preserves sorted order" { + // Documents the correctness contract for the O(log n) binary-search replacement. + // Currently correct but O(n); fix replaces linear scan with bsearch + single remove. + const PostingList = @import("index.zig").PostingList; + var list = PostingList{}; + defer list.items.deinit(testing.allocator); + + var id: u32 = 0; + while (id < 100) : (id += 1) { + const p = try list.getOrAddPosting(testing.allocator, id * 2); // even doc_ids 0..198 + p.loc_mask = 0xFF; + } + + list.removeDocId(50); + try testing.expectEqual(@as(usize, 99), list.items.items.len); + try testing.expect(list.getByDocId(48) != null); + try testing.expect(list.getByDocId(50) == null); + try testing.expect(list.getByDocId(52) != null); + + // Sorted invariant must hold after removal. + for (1..list.items.items.len) |k| { + try testing.expect(list.items.items[k].doc_id > list.items.items[k - 1].doc_id); + } +} + +test "issue-249: nuke.removeJsonMcpServerEntry returns null when key absent" { + // Verifies removeJsonMcpServerEntry does not signal a write when key is absent, + // which ensures the non-atomic rewriteConfigFile path is never triggered unnecessarily. + const result = try nuke_mod.removeJsonMcpServerEntry(testing.allocator, "{\"other\":1}", "codedb"); + try testing.expect(result == null); +} + +test "issue-250: searchContent finds content in files skipped by trigram index" { + // Files indexed with skip_trigram=true (e.g. past the 15k cap) must still be + // reachable via the fallback full-scan path in searchContent. + var explorer = Explorer.init(testing.allocator); + defer explorer.deinit(); + + try explorer.indexFileSkipTrigram("large.zig", "fn unique_zzz_sentinel() void {}"); + + const results = try explorer.searchContent("unique_zzz_sentinel", testing.allocator, 10); + defer { + for (results) |r| { + testing.allocator.free(r.path); + testing.allocator.free(r.line_text); + } + testing.allocator.free(results); + } + try testing.expectEqual(@as(usize, 1), results.len); +} diff --git a/src/update.zig b/src/update.zig new file mode 100644 index 0000000..ffdad28 --- /dev/null +++ b/src/update.zig @@ -0,0 +1,296 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const sty = @import("style.zig"); +const release_info = @import("release_info.zig"); + +const github_repo = "justrach/codedb"; +const default_base_url = "https://codedb.codegraff.com"; +const user_agent = "codedb-update"; + +const Out = struct { + file: std.fs.File, + alloc: std.mem.Allocator, + + fn p(self: Out, comptime fmt: []const u8, args: anytype) void { + const str = std.fmt.allocPrint(self.alloc, fmt, args) catch return; + defer self.alloc.free(str); + self.file.writeAll(str) catch {}; + } +}; + +const VersionSource = enum { + env, + github, + fallback, +}; + +const ResolvedVersion = struct { + value: []u8, + source: VersionSource, +}; + +pub fn run(stdout: std.fs.File, s: sty.Style, allocator: std.mem.Allocator) void { + const out = Out{ .file = stdout, .alloc = allocator }; + + const resolved = resolveTargetVersion(allocator) catch |err| { + out.p("{s}✗{s} failed to resolve update target: {s}\n", .{ s.red, s.reset, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(resolved.value); + + const version_order = compareVersions(release_info.semver, resolved.value) catch |err| { + out.p("{s}✗{s} invalid release version: {s}\n", .{ s.red, s.reset, @errorName(err) }); + std.process.exit(1); + }; + + switch (version_order) { + .eq => { + out.p("codedb {s} is already up to date\n", .{release_info.semver}); + return; + }, + .gt => { + out.p("{s}✗{s} refusing to replace codedb {s} with older release {s}\n", .{ s.red, s.reset, release_info.semver, resolved.value }); + std.process.exit(1); + }, + .lt => {}, + } + + const asset_name = assetNameForTarget(builtin.os.tag, builtin.cpu.arch) orelse { + out.p("{s}✗{s} self-update is unsupported on this platform\n", .{ s.red, s.reset }); + std.process.exit(1); + }; + + out.p("updating codedb {s} -> {s}\n", .{ release_info.semver, resolved.value }); + out.p(" source: {s}\n", .{switch (resolved.source) { + .env => "CODEDB_VERSION", + .github => "github releases", + .fallback => "codedb.codegraff.com/latest.json", + }}); + out.p(" asset: {s}\n", .{asset_name}); + + const manifest = fetchChecksumsManifest(allocator, resolved.value) catch |err| { + out.p("{s}✗{s} failed to download checksums for v{s}: {s}\n", .{ s.red, s.reset, resolved.value, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(manifest); + + const expected_hash = checksumForBinary(manifest, asset_name) orelse { + out.p("{s}✗{s} release v{s} is missing a checksum for {s}\n", .{ s.red, s.reset, resolved.value, asset_name }); + std.process.exit(1); + }; + + const self_path = std.fs.selfExePathAlloc(allocator) catch |err| { + out.p("{s}✗{s} cannot locate current executable: {s}\n", .{ s.red, s.reset, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(self_path); + + downloadAndReplaceBinary(allocator, resolved.value, asset_name, self_path, expected_hash) catch |err| { + out.p("{s}✗{s} update failed: {s}\n", .{ s.red, s.reset, @errorName(err) }); + std.process.exit(1); + }; + + out.p("{s}✓{s} updated to codedb {s}\n", .{ s.green, s.reset, resolved.value }); +} + +pub fn assetNameForTarget(os_tag: std.Target.Os.Tag, arch: std.Target.Cpu.Arch) ?[]const u8 { + return switch (os_tag) { + .macos => switch (arch) { + .aarch64 => "codedb-darwin-arm64", + .x86_64 => "codedb-darwin-x86_64", + else => null, + }, + .linux => switch (arch) { + .aarch64 => "codedb-linux-arm64", + .x86_64 => "codedb-linux-x86_64", + else => null, + }, + else => null, + }; +} + +pub fn compareVersions(current: []const u8, target: []const u8) !std.math.Order { + var current_it = std.mem.splitScalar(u8, trimVersionPrefix(current), '.'); + var target_it = std.mem.splitScalar(u8, trimVersionPrefix(target), '.'); + + while (true) { + const current_part = current_it.next(); + const target_part = target_it.next(); + + if (current_part == null and target_part == null) return .eq; + + const current_num = if (current_part) |part| try parseVersionPart(part) else 0; + const target_num = if (target_part) |part| try parseVersionPart(part) else 0; + + if (current_num < target_num) return .lt; + if (current_num > target_num) return .gt; + } +} + +pub fn checksumForBinary(manifest: []const u8, binary_name: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, manifest, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0) continue; + + const hash_end = std.mem.indexOfAny(u8, line, " \t") orelse continue; + const hash = line[0..hash_end]; + var name = std.mem.trimLeft(u8, line[hash_end..], " \t"); + if (name.len == 0) continue; + if (name[0] == '*') name = name[1..]; + if (std.mem.eql(u8, name, binary_name)) return hash; + } + + return null; +} + +fn resolveTargetVersion(allocator: std.mem.Allocator) !ResolvedVersion { + const explicit = std.process.getEnvVarOwned(allocator, "CODEDB_VERSION") catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + if (explicit) |value| { + return .{ .value = value, .source = .env }; + } + + if (fetchLatestVersionFromGitHub(allocator) catch null) |value| { + return .{ .value = value, .source = .github }; + } + + if (fetchLatestVersionFromFallback(allocator) catch null) |value| { + return .{ .value = value, .source = .fallback }; + } + + return error.CouldNotResolveLatestVersion; +} + +fn fetchLatestVersionFromGitHub(allocator: std.mem.Allocator) !?[]u8 { + const response = fetchUrlToMemory(allocator, "https://api.github.com/repos/" ++ github_repo ++ "/releases/latest", 1 * 1024 * 1024) catch return null; + defer allocator.free(response); + return parseJsonStringField(allocator, response, "tag_name", true); +} + +fn fetchLatestVersionFromFallback(allocator: std.mem.Allocator) !?[]u8 { + const base_url = try getBaseUrl(allocator); + defer if (base_url.owned) allocator.free(base_url.value); + + const url = try std.fmt.allocPrint(allocator, "{s}/latest.json", .{base_url.value}); + defer allocator.free(url); + + const response = fetchUrlToMemory(allocator, url, 256 * 1024) catch return null; + defer allocator.free(response); + return parseJsonStringField(allocator, response, "version", false); +} + +fn fetchChecksumsManifest(allocator: std.mem.Allocator, version: []const u8) ![]u8 { + const url = try std.fmt.allocPrint(allocator, "https://github.com/{s}/releases/download/v{s}/checksums.sha256", .{ github_repo, version }); + defer allocator.free(url); + return fetchUrlToMemory(allocator, url, 256 * 1024); +} + +fn fetchUrlToMemory(allocator: std.mem.Allocator, url: []const u8, max_output_bytes: usize) ![]u8 { + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "curl", "-fsSL", "-A", user_agent, url }, + .max_output_bytes = max_output_bytes, + }); + defer allocator.free(result.stderr); + + if (result.term != .Exited or result.term.Exited != 0) { + allocator.free(result.stdout); + return error.CurlFailed; + } + + return result.stdout; +} + +fn parseJsonStringField(allocator: std.mem.Allocator, json_text: []const u8, field_name: []const u8, trim_v_prefix: bool) !?[]u8 { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, json_text, .{}) catch return null; + defer parsed.deinit(); + + if (parsed.value != .object) return null; + const field = parsed.value.object.get(field_name) orelse return null; + if (field != .string) return null; + + const value = if (trim_v_prefix) trimVersionPrefix(field.string) else field.string; + if (value.len == 0) return null; + return try allocator.dupe(u8, value); +} + +fn getBaseUrl(allocator: std.mem.Allocator) !struct { value: []const u8, owned: bool } { + const env_value = std.process.getEnvVarOwned(allocator, "CODEDB_URL") catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + if (env_value) |value| { + return .{ .value = value, .owned = true }; + } + return .{ .value = default_base_url, .owned = false }; +} + +fn trimVersionPrefix(value: []const u8) []const u8 { + return std.mem.trimLeft(u8, value, "vV"); +} + +fn parseVersionPart(part: []const u8) !u64 { + const trimmed = std.mem.trim(u8, part, " \t\r\n"); + if (trimmed.len == 0) return error.InvalidVersion; + return std.fmt.parseInt(u64, trimmed, 10); +} + +fn downloadAndReplaceBinary(allocator: std.mem.Allocator, version: []const u8, asset_name: []const u8, dest_path: []const u8, expected_hash: []const u8) !void { + const url = try std.fmt.allocPrint(allocator, "https://github.com/{s}/releases/download/v{s}/{s}", .{ github_repo, version, asset_name }); + defer allocator.free(url); + + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp.{d}", .{ dest_path, std.time.nanoTimestamp() }); + defer allocator.free(tmp_path); + errdefer std.fs.deleteFileAbsolute(tmp_path) catch {}; + + try downloadToFile(allocator, url, tmp_path); + + const actual_hash = try sha256FileHex(allocator, tmp_path); + defer allocator.free(actual_hash); + if (!std.ascii.eqlIgnoreCase(actual_hash, expected_hash)) { + return error.ChecksumMismatch; + } + + { + var tmp_file = try std.fs.openFileAbsolute(tmp_path, .{ .mode = .read_write }); + defer tmp_file.close(); + try tmp_file.chmod(0o755); + } + + try std.fs.renameAbsolute(tmp_path, dest_path); +} + +fn downloadToFile(allocator: std.mem.Allocator, url: []const u8, dest_path: []const u8) !void { + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "curl", "-fsSL", "-A", user_agent, url, "-o", dest_path }, + .max_output_bytes = 16 * 1024, + }); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + if (result.term != .Exited or result.term.Exited != 0) { + return error.DownloadFailed; + } +} + +fn sha256FileHex(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var file = try std.fs.openFileAbsolute(path, .{}); + defer file.close(); + + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + var buf: [16 * 1024]u8 = undefined; + while (true) { + const read_len = try file.read(&buf); + if (read_len == 0) break; + hasher.update(buf[0..read_len]); + } + + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + hasher.final(&digest); + const digest_hex = std.fmt.bytesToHex(digest, .lower); + return allocator.dupe(u8, &digest_hex); +} diff --git a/src/watcher.zig b/src/watcher.zig index 579765d..e6334ea 100644 --- a/src/watcher.zig +++ b/src/watcher.zig @@ -37,20 +37,19 @@ pub const EventQueue = struct { const CAPACITY = 4096; events: [CAPACITY]?FsEvent = [_]?FsEvent{null} ** CAPACITY, - head: std.atomic.Value(usize) = std.atomic.Value(usize).init(0), - tail: std.atomic.Value(usize) = std.atomic.Value(usize).init(0), + head: usize = 0, + tail: usize = 0, mu: std.Thread.Mutex = .{}, pub fn push(self: *EventQueue, event: FsEvent) bool { self.mu.lock(); defer self.mu.unlock(); - // Mutex provides the memory ordering guarantee; .monotonic is sufficient here. - const cur_tail = self.tail.load(.monotonic); + const cur_tail = self.tail; const next_tail = (cur_tail + 1) % CAPACITY; - if (next_tail == self.head.load(.monotonic)) return false; + if (next_tail == self.head) return false; self.events[cur_tail] = event; - self.tail.store(next_tail, .monotonic); + self.tail = next_tail; return true; } @@ -58,11 +57,10 @@ pub const EventQueue = struct { self.mu.lock(); defer self.mu.unlock(); - // Mutex provides the memory ordering guarantee; .monotonic is sufficient here. - const cur_head = self.head.load(.monotonic); - if (cur_head == self.tail.load(.monotonic)) return null; + const cur_head = self.head; + if (cur_head == self.tail) return null; const event = self.events[cur_head]; - self.head.store((cur_head + 1) % CAPACITY, .monotonic); + self.head = (cur_head + 1) % CAPACITY; return event; } }; @@ -531,6 +529,14 @@ pub fn incrementalLoop(store: *Store, explorer: *Explorer, queue: *EventQueue, r // Track current git HEAD to detect branch switches (#116) var last_git_head: ?[40]u8 = git_mod.getGitHead(root, backing) catch null; + // Cache .git/HEAD mtime so we only fork git rev-parse when the file changes (#254) + var git_head_mtime: i128 = blk: { + var root_dir = std.fs.cwd().openDir(root, .{}) catch break :blk -1; + defer root_dir.close(); + const st = compat.dirStatFile(root_dir, ".git/HEAD") catch break :blk -1; + break :blk st.mtime; + }; + while (!shutdown.load(.acquire)) { // Check for muonry edit notifications (instant re-index, no 2s delay) drainNotifyFile(store, explorer, queue, &known, root, backing); @@ -538,14 +544,23 @@ pub fn incrementalLoop(store: *Store, explorer: *Explorer, queue: *EventQueue, r // Poll every 2s — gentle on CPU, fast enough to catch saves std.Thread.sleep(2 * std.time.ns_per_s); - // Check if git HEAD changed (branch switch, checkout, rebase) - const current_head = git_mod.getGitHead(root, backing) catch null; + // Check if git HEAD changed — stat .git/HEAD mtime first to skip fork+exec (#254) + var current_head: ?[40]u8 = last_git_head; const head_changed = blk: { + { + var root_dir = std.fs.cwd().openDir(root, .{}) catch break :blk false; + defer root_dir.close(); + const st = compat.dirStatFile(root_dir, ".git/HEAD") catch break :blk false; + if (st.mtime == git_head_mtime) break :blk false; + git_head_mtime = st.mtime; + } + current_head = git_mod.getGitHead(root, backing) catch null; if (last_git_head == null and current_head == null) break :blk false; if (last_git_head == null or current_head == null) break :blk true; break :blk !std.mem.eql(u8, &last_git_head.?, ¤t_head.?); }; + if (head_changed) { std.log.info("git HEAD changed — re-scanning", .{}); last_git_head = current_head;