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
4 changes: 4 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,8 @@ Current stable codes and triggers:
| Memory tracing | `index --json --memory-trace` adds a `memory_timeline` block to the CLI index result and persists peak working-set MB into `last_index_run`; dry-run results also emit live `start`, `snapshot`, `scan`, and `finalize` samples but never persist run metadata. `index --dry-run --rebuild` bypasses destructive confirmation because it does not delete or rewrite the index. `CDIDX_MEM_WARN_MB=<mb>` prints a warning when the sampled working set crosses that threshold. |
| Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. |

`vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics.

### Data directory resolution

When `--db <path>` is omitted, cdidx resolves the SQLite location from a data directory and appends `codeindex.db`. The precedence chain is:
Expand Down Expand Up @@ -5228,6 +5230,8 @@ apply 時は `PRAGMA optimize` を実行します。
| memory tracing | `index --json --memory-trace` は CLI index 結果に `memory_timeline` block を追加し、peak working-set MB を `last_index_run` に保存します。dry-run 結果も live な `start`、`snapshot`、`scan`、`finalize` sample を返しますが、run metadata は保存しません。`index --dry-run --rebuild` は index を削除も rewrite もしないため destructive confirmation を bypass します。`CDIDX_MEM_WARN_MB=<mb>` は sampled working set がしきい値を超えたときに warning を出します。 |
| newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 |

`vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。

### データディレクトリ解決

`--db <path>` が省略された場合、cdidx は data directory を解決し、その下に `codeindex.db` を置く。優先順位は次のとおりです。
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/5123.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 5123
affected:
- src/CodeIndex/PathUriNormalizer.cs
- src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs
- tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **`vacuum --dry-run` now accepts single-slash local file URIs (#5123)** — Unix `file:/absolute/path` and Windows `file:/C:/absolute/path` spellings are canonicalized while preserving the original query string and ignoring URI fragments, and validation reads the same snapshot used for metrics, including explicit `immutable=1` semantics.

## 日本語

- **`vacuum --dry-run` が single-slash のローカル file URI を受け付けるようになりました (#5123)** — Unix の `file:/absolute/path` と Windows の `file:/C:/absolute/path` を、元の query string を維持して URI fragment を無視しつつ canonicalize し、validation と metric 収集で同じ snapshot を読むため、明示的な `immutable=1` semantics も維持します。
43 changes: 41 additions & 2 deletions src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,17 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions,
MaintenanceDatabaseFailureKind.NotWritable));
}

var queryOnlyDbPath = options.DryRun
? StripFileUriFragment(options.DbPath)
: options.DbPath;
if (options.DryRun
&& TryCanonicalizeSingleSlashFileUri(queryOnlyDbPath, out var canonicalDbUri))
{
queryOnlyDbPath = canonicalDbUri;
}

if (!DbContext.TryValidateExistingCodeIndexDb(
options.DbPath,
options.DryRun ? queryOnlyDbPath : options.DbPath,
requireWritable: !options.DryRun,
requireSupportedUserVersion: false,
out _,
Expand All @@ -82,7 +91,7 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions,
DbContext.VacuumGenerationWitness? vacuumGenerationWitness;
using (var db = DbContext.CreateUnpooled(
options.DryRun ? DbOpenIntent.QueryOnly : DbOpenIntent.Repair,
options.DbPath,
options.DryRun ? queryOnlyDbPath : options.DbPath,
cancellationToken))
{
db.SuppressPlannerStatisticsMaintenanceOnClose();
Expand Down Expand Up @@ -163,4 +172,34 @@ private static void WriteVacuumByteTransition(string label, long? before, long?
if (before.HasValue && after.HasValue)
Console.WriteLine(ConsoleUi.FormatSummaryLine(label, $"{before.Value:N0} -> {after.Value:N0} bytes"));
}

private static bool TryCanonicalizeSingleSlashFileUri(string originalDbUri, out string canonicalDbUri)
{
canonicalDbUri = originalDbUri;
if (!originalDbUri.StartsWith("file:/", StringComparison.OrdinalIgnoreCase)
|| originalDbUri.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
return false;
}

if (!DbPathResolver.TryNormalizeDbPath(originalDbUri, out var normalizedDbPath, out _)
|| SqliteFileUri.StartsWithFileScheme(normalizedDbPath))
{
return false;
}

var queryIndex = originalDbUri.IndexOf('?', StringComparison.Ordinal);
var querySuffix = queryIndex >= 0 ? originalDbUri[queryIndex..] : string.Empty;
canonicalDbUri = CodeIndex.FileUriPolicy.PathToFileUri(Path.GetFullPath(normalizedDbPath)) + querySuffix;
return true;
}

private static string StripFileUriFragment(string dbPath)
{
if (!SqliteFileUri.StartsWithFileScheme(dbPath))
return dbPath;

var fragmentIndex = dbPath.IndexOf('#', StringComparison.Ordinal);
return fragmentIndex >= 0 ? dbPath[..fragmentIndex] : dbPath;
}
}
9 changes: 9 additions & 0 deletions src/CodeIndex/PathUriNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ internal static bool TryNormalizeFileUriPath(string fileUri, out string normaliz
var relativePath = Uri.UnescapeDataString(pathText["file:".Length..]);
if (string.IsNullOrWhiteSpace(relativePath))
return true;
if (OperatingSystem.IsWindows() && HasSingleSlashWindowsDrivePrefix(relativePath))
relativePath = relativePath[1..];
normalizedPath = Path.GetFullPath(relativePath);
return true;
}
Expand Down Expand Up @@ -126,6 +128,13 @@ internal static bool HasWindowsDrivePrefix(string path)
&& path[1] == ':'
&& ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z'));

private static bool HasSingleSlashWindowsDrivePrefix(string path)
=> path.Length >= 4
&& path[0] is '/' or '\\'
&& path[2] == ':'
&& path[3] is '/' or '\\'
&& ((path[1] >= 'A' && path[1] <= 'Z') || (path[1] >= 'a' && path[1] <= 'z'));

private static string StripQuery(string uri)
{
var query = uri.IndexOf('?');
Expand Down
85 changes: 85 additions & 0 deletions tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,91 @@ INSERT INTO vacuum_payload (payload)
DbWriter.DefaultFtsOptimizeIncrementalWriteThreshold,
ftsOptimization.GetProperty("observed_writes").GetInt64());
Assert.Equal("current", ftsOptimization.GetProperty("state").GetString());

using (var walDb = new DbContext(DbOpenIntent.WriteIndex, dbPath))
{
walDb.SuppressPlannerStatisticsMaintenanceOnClose();
walDb.CheckpointWalTruncate();
new DbWriter(walDb).SetMeta(DbWriter.FtsIncrementalWritesSinceOptimizeMetaKey, "0");
}

var canonicalDbUri = new Uri(dbPath).AbsoluteUri;
Assert.StartsWith("file:///", canonicalDbUri, StringComparison.OrdinalIgnoreCase);
var singleSlashDbUri = "file:/" + canonicalDbUri["file:///".Length..];
foreach (var (canonicalUri, singleSlashUri, expectedFtsState) in new[]
{
(canonicalDbUri + "?immutable=1", singleSlashDbUri + "?immutable=1", "stale"),
(canonicalDbUri + "?immutable=1#ignored", singleSlashDbUri + "?immutable=1#ignored", "stale"),
(canonicalDbUri + "#ignored", singleSlashDbUri + "#ignored", "current"),
(canonicalDbUri, singleSlashDbUri, "current"),
})
{
var (canonicalExitCode, canonicalStdout, canonicalStderr) = CaptureVacuum(
["--db", canonicalUri, "--dry-run", "--json"]);
var (uriExitCode, uriStdout, uriStderr) = CaptureVacuum(
["--db", singleSlashUri, "--dry-run", "--json"]);

Assert.Equal(CommandExitCodes.Success, canonicalExitCode);
Assert.Equal(string.Empty, canonicalStderr);
Assert.Equal(CommandExitCodes.Success, uriExitCode);
Assert.Equal(string.Empty, uriStderr);
using var canonicalDocument = ParseJsonOutput(canonicalStdout);
using var uriDocument = ParseJsonOutput(uriStdout);
var uriRoot = uriDocument.RootElement;
Assert.Equal("dry_run", uriRoot.GetProperty("status").GetString());
Assert.True(uriRoot.GetProperty("dry_run").GetBoolean());
var canonicalFtsOptimization = canonicalDocument.RootElement
.GetProperty("maintenance_guidance")
.GetProperty("fts_optimization");
Assert.Equal(
expectedFtsState,
canonicalFtsOptimization.GetProperty("state").GetString());
var uriFtsOptimization = uriRoot
.GetProperty("maintenance_guidance")
.GetProperty("fts_optimization");
Assert.Equal(
canonicalFtsOptimization.GetProperty("recommended").GetBoolean(),
uriFtsOptimization.GetProperty("recommended").GetBoolean());
Assert.Equal(
canonicalFtsOptimization.GetProperty("action").GetString(),
uriFtsOptimization.GetProperty("action").GetString());
Assert.Equal(
canonicalFtsOptimization.GetProperty("reason").GetString(),
uriFtsOptimization.GetProperty("reason").GetString());
Assert.Equal(
canonicalFtsOptimization.GetProperty("threshold_writes").GetInt32(),
uriFtsOptimization.GetProperty("threshold_writes").GetInt32());
Assert.Equal(
canonicalFtsOptimization.GetProperty("observed_writes").GetInt64(),
uriFtsOptimization.GetProperty("observed_writes").GetInt64());
Assert.Equal(
canonicalFtsOptimization.GetProperty("state").GetString(),
uriFtsOptimization.GetProperty("state").GetString());
}

using (var invalidWalDb = new DbContext(DbOpenIntent.WriteIndex, dbPath))
{
invalidWalDb.SuppressPlannerStatisticsMaintenanceOnClose();
invalidWalDb.CheckpointWalTruncate();
using var command = invalidWalDb.Connection.CreateCommand();
command.CommandText = "PRAGMA application_id=0";
command.ExecuteNonQuery();
}

foreach (var immutableUri in new[]
{
canonicalDbUri + "?immutable=1",
singleSlashDbUri + "?immutable=1",
})
{
var (immutableExitCode, immutableStdout, immutableStderr) = CaptureVacuum(
["--db", immutableUri, "--dry-run", "--json"]);

Assert.Equal(CommandExitCodes.Success, immutableExitCode);
Assert.Equal(string.Empty, immutableStderr);
using var immutableDocument = ParseJsonOutput(immutableStdout);
Assert.Equal("dry_run", immutableDocument.RootElement.GetProperty("status").GetString());
}
}

[Fact]
Expand Down
Loading