nilometer 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/core/schema/032_stored_views.sql +233 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +881 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +219 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/ingest/stored-views.js +65 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/core/view/server.js +142 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1717 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- package/package.json +61 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Opening the SQLite database and applying numbered SQL migrations (docs/development.md P3.1).
|
|
3
|
+
*
|
|
4
|
+
* Implements README § Tech stack ("analysis logic lives in SQL") and D-093 (Node's built-in `node:sqlite`,
|
|
5
|
+
* which replaced better-sqlite3 so an npm install needs no native module).
|
|
6
|
+
* Schema is owned by numbered files in `core/schema/` (`001_name.sql`, `002_name.sql`, ...), never
|
|
7
|
+
* by application code. Migrations run every time the database is opened, including for read-only
|
|
8
|
+
* commands, so an older database is always brought up to date before it is queried. phuryn's
|
|
9
|
+
* tool crashed reading an old schema because only its write path migrated (phuryn#153).
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { ensurePrivateDir, ensurePrivateFile, tightenMode } from "../install/private-files.js";
|
|
15
|
+
/**
|
|
16
|
+
* Runs `work` in one transaction: committed when it returns, rolled back when it throws. Inside another
|
|
17
|
+
* transaction it becomes a savepoint, so an inner failure undoes only the inner work, as better-sqlite3's
|
|
18
|
+
* `db.transaction()` did before D-093.
|
|
19
|
+
* @param db - An open database.
|
|
20
|
+
* @param work - The statements to run together.
|
|
21
|
+
* @returns What `work` returns.
|
|
22
|
+
* @throws {Error} Whatever `work` throws, after the rollback.
|
|
23
|
+
*/
|
|
24
|
+
export function transaction(db, work) {
|
|
25
|
+
const nested = db.isTransaction;
|
|
26
|
+
// A savepoint name only has to be unique among the ones open at once, and nesting is strictly LIFO.
|
|
27
|
+
const begin = nested ? "SAVEPOINT nilometer_nested" : "BEGIN";
|
|
28
|
+
db.exec(begin);
|
|
29
|
+
try {
|
|
30
|
+
const result = work();
|
|
31
|
+
db.exec(nested ? "RELEASE nilometer_nested" : "COMMIT");
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (nested) {
|
|
36
|
+
// ROLLBACK TO keeps the savepoint open, so it's released too, leaving the outer transaction as it was.
|
|
37
|
+
db.exec("ROLLBACK TO nilometer_nested");
|
|
38
|
+
db.exec("RELEASE nilometer_nested");
|
|
39
|
+
}
|
|
40
|
+
else if (db.isTransaction) {
|
|
41
|
+
// SQLite may already have rolled back on its own (a full disk, an interrupt); rolling back twice errors.
|
|
42
|
+
db.exec("ROLLBACK");
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Views a BLOB read from the database as a Buffer, without copying it. node:sqlite returns blobs as
|
|
49
|
+
* plain `Uint8Array`s, whose `toString("utf8")` doesn't decode: it joins the byte values with commas,
|
|
50
|
+
* so a line read without this would parse as malformed instead of failing (D-093).
|
|
51
|
+
* @param blob - A BLOB column's value.
|
|
52
|
+
* @returns The same bytes as a Buffer.
|
|
53
|
+
*/
|
|
54
|
+
export function blobBytes(blob) {
|
|
55
|
+
return Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Reads one pragma's value.
|
|
59
|
+
* @param db - An open database.
|
|
60
|
+
* @param name - The pragma, e.g. `journal_mode`.
|
|
61
|
+
* @returns The first column of its first row, or undefined when it returns none.
|
|
62
|
+
*/
|
|
63
|
+
export function pragmaValue(db, name) {
|
|
64
|
+
const row = db.prepare(`PRAGMA ${name}`).get();
|
|
65
|
+
return row === undefined ? undefined : Object.values(row)[0];
|
|
66
|
+
}
|
|
67
|
+
/** A migrations directory that is misnumbered. Nothing has been applied when this is raised. */
|
|
68
|
+
export class MigrationListError extends Error {
|
|
69
|
+
/**
|
|
70
|
+
* Creates a migration list error.
|
|
71
|
+
* @param message - What is wrong with the directory.
|
|
72
|
+
*/
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "MigrationListError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Pattern every migration file name must match: a zero-padded number, underscore, name, `.sql`. */
|
|
79
|
+
export const MIGRATION_FILE = /^(\d{3})_[a-z0-9_]+\.sql$/;
|
|
80
|
+
/**
|
|
81
|
+
* Reads and orders the migration files in a directory.
|
|
82
|
+
* @param dir - Directory containing `NNN_name.sql` files.
|
|
83
|
+
* @returns Migrations in version order, starting at 1 with no gaps.
|
|
84
|
+
* @throws {MigrationListError} If a `.sql` file is misnamed, a version repeats, or versions have gaps.
|
|
85
|
+
*/
|
|
86
|
+
export function listMigrations(dir) {
|
|
87
|
+
const migrations = [];
|
|
88
|
+
for (const name of readdirSync(dir).sort()) {
|
|
89
|
+
if (!name.endsWith(".sql")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const match = MIGRATION_FILE.exec(name);
|
|
93
|
+
// A misnamed file would silently never run, which is worse than refusing to start.
|
|
94
|
+
if (match === null) {
|
|
95
|
+
throw new MigrationListError(`migration file ${name} doesn't match NNN_name.sql`);
|
|
96
|
+
}
|
|
97
|
+
migrations.push({
|
|
98
|
+
version: Number(match[1]),
|
|
99
|
+
name,
|
|
100
|
+
sql: readFileSync(join(dir, name), "utf8"),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
migrations.forEach((migration, index) => {
|
|
104
|
+
// Versions must be exactly 1..N: a duplicate or a gap means a lost or conflicting file.
|
|
105
|
+
if (migration.version !== index + 1) {
|
|
106
|
+
throw new MigrationListError(`migration versions must run 1..N without gaps or repeats; found ${migration.name} at position ${index + 1}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return migrations;
|
|
110
|
+
}
|
|
111
|
+
/** SQL creating the table that records applied migrations. */
|
|
112
|
+
const MIGRATIONS_TABLE = `
|
|
113
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
114
|
+
version INTEGER PRIMARY KEY,
|
|
115
|
+
name TEXT NOT NULL,
|
|
116
|
+
applied_at TEXT NOT NULL
|
|
117
|
+
)`;
|
|
118
|
+
/**
|
|
119
|
+
* Returns the highest applied migration version.
|
|
120
|
+
* @param db - An open database.
|
|
121
|
+
* @returns 0 for a new database, otherwise the last applied version.
|
|
122
|
+
*/
|
|
123
|
+
export function schemaVersion(db) {
|
|
124
|
+
db.exec(MIGRATIONS_TABLE);
|
|
125
|
+
const row = db
|
|
126
|
+
.prepare("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations")
|
|
127
|
+
.get();
|
|
128
|
+
return row.version;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Applies every migration newer than the database's current version, each in its own transaction.
|
|
132
|
+
* @param db - An open database.
|
|
133
|
+
* @param migrations - Migrations from {@link listMigrations}.
|
|
134
|
+
* @param now - Clock for `applied_at`, injected for tests.
|
|
135
|
+
* @returns The versions applied by this call, in order; empty when already up to date.
|
|
136
|
+
* @throws {Error} Whatever SQLite raises for a failing migration. That migration is rolled back
|
|
137
|
+
* completely, and earlier migrations from the same call stay applied.
|
|
138
|
+
*/
|
|
139
|
+
export function applyMigrations(db, migrations, now = () => new Date()) {
|
|
140
|
+
const current = schemaVersion(db);
|
|
141
|
+
const record = db.prepare("INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)");
|
|
142
|
+
const applied = [];
|
|
143
|
+
for (const migration of migrations.filter((m) => m.version > current)) {
|
|
144
|
+
// A transaction per migration: a failure leaves the schema at the last complete version.
|
|
145
|
+
transaction(db, () => {
|
|
146
|
+
db.exec(migration.sql);
|
|
147
|
+
record.run(migration.version, migration.name, now().toISOString());
|
|
148
|
+
});
|
|
149
|
+
applied.push(migration.version);
|
|
150
|
+
}
|
|
151
|
+
return applied;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Opens (creating if needed) the database and brings its schema up to date.
|
|
155
|
+
* @param path - Database file path, or `:memory:` for tests.
|
|
156
|
+
* @param migrationsDir - Directory of migration files.
|
|
157
|
+
* @returns An open database with WAL journaling, a busy timeout, and foreign keys enforced.
|
|
158
|
+
* @throws {MigrationListError} If the migrations directory is misnumbered.
|
|
159
|
+
* @throws {Error} Whatever SQLite raises while opening or migrating.
|
|
160
|
+
*/
|
|
161
|
+
export function openDatabase(path, migrationsDir) {
|
|
162
|
+
const migrations = listMigrations(migrationsDir);
|
|
163
|
+
if (path !== ":memory:") {
|
|
164
|
+
ensurePrivateDir(dirname(path));
|
|
165
|
+
// SQLite creates the -wal and -shm files with the database file's mode, so creating the file
|
|
166
|
+
// owner-only first keeps all three private (D-043). An existing file is tightened, never loosened.
|
|
167
|
+
ensurePrivateFile(path);
|
|
168
|
+
for (const file of [path, `${path}-wal`, `${path}-shm`]) {
|
|
169
|
+
tightenMode(file);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// The timeout is SQLite's busy timeout: it waits out short write locks instead of failing at once.
|
|
173
|
+
const db = new DatabaseSync(path, { timeout: 5000 });
|
|
174
|
+
// WAL lets the viewer read while an ingest writes.
|
|
175
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
176
|
+
// SQLite leaves foreign keys off unless asked, per connection. node:sqlite turns them on by default;
|
|
177
|
+
// saying so here keeps the requirement visible rather than inherited.
|
|
178
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
179
|
+
applyMigrations(db, migrations);
|
|
180
|
+
return db;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=database.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Content fingerprints of database tables (docs/development.md P3.3, audit check A8).
|
|
3
|
+
*
|
|
4
|
+
* Proves re-runnability (D-002, `verify-against-ccusage` skill, check 4): fingerprint the derived
|
|
5
|
+
* tables, ingest again, and the fingerprints must be byte-identical. A fingerprint depends only on
|
|
6
|
+
* table contents, never on physical row order, so two databases holding the same rows agree even if
|
|
7
|
+
* the rows were inserted in a different order.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { blobBytes } from "./database.js";
|
|
11
|
+
/** A table name that doesn't exist. Nothing is hashed when this is raised. */
|
|
12
|
+
export class UnknownTableError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* Creates an unknown-table error.
|
|
15
|
+
* @param table - The name that wasn't found.
|
|
16
|
+
*/
|
|
17
|
+
constructor(table) {
|
|
18
|
+
super(`no such table: ${table}`);
|
|
19
|
+
this.name = "UnknownTableError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Serializes one SQLite value unambiguously.
|
|
24
|
+
* @param value - A value as `node:sqlite` returns it (a blob is a `Uint8Array`; a `Buffer` is one too).
|
|
25
|
+
* @returns A JSON-safe value. Blobs become `{"blob": "<hex>"}` so they can never collide with text,
|
|
26
|
+
* and bigints become `{"int": "<decimal>"}` so large integers keep full precision.
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalValue(value) {
|
|
29
|
+
if (value instanceof Uint8Array) {
|
|
30
|
+
return { blob: blobBytes(value).toString("hex") };
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === "bigint") {
|
|
33
|
+
return { int: value.toString() };
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Computes a SHA-256 fingerprint for each named table.
|
|
39
|
+
* @param db - An open database.
|
|
40
|
+
* @param tableNames - Tables to fingerprint.
|
|
41
|
+
* @returns Lowercase hex digests keyed by table name.
|
|
42
|
+
* @throws {UnknownTableError} If any table doesn't exist.
|
|
43
|
+
*/
|
|
44
|
+
export function fingerprintTables(db, tableNames) {
|
|
45
|
+
const existing = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
46
|
+
// Check every name before hashing any, so a typo can't produce a partial result.
|
|
47
|
+
for (const table of tableNames) {
|
|
48
|
+
if (!existing.has(table)) {
|
|
49
|
+
throw new UnknownTableError(table);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const table of tableNames) {
|
|
54
|
+
// The name was validated against sqlite_master above; quoting guards unusual characters.
|
|
55
|
+
const rows = db.prepare(`SELECT * FROM "${table.replaceAll('"', '""')}"`);
|
|
56
|
+
// Arrays rather than objects: column order is part of the content, and names are not.
|
|
57
|
+
rows.setReturnArrays(true);
|
|
58
|
+
const values = rows.all();
|
|
59
|
+
// Sort serialized rows so the digest ignores physical order but not content.
|
|
60
|
+
const serialized = values.map((row) => JSON.stringify(row.map(canonicalValue))).sort();
|
|
61
|
+
const hash = createHash("sha256");
|
|
62
|
+
for (const row of serialized) {
|
|
63
|
+
hash.update(row);
|
|
64
|
+
// A separator makes row boundaries part of the digest: ["ab"] and ["a","b"] differ.
|
|
65
|
+
hash.update("\n");
|
|
66
|
+
}
|
|
67
|
+
result[table] = hash.digest("hex");
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=fingerprint.js.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Attributing working directories to repositories through git (docs/development.md P4.6, D-010, D-028).
|
|
3
|
+
*
|
|
4
|
+
* A repository is identified by git's common directory, not by the working directory's name. A
|
|
5
|
+
* worktree is a different directory but the same repository, and a subdirectory is not a separate
|
|
6
|
+
* project. ccusage (encoded directory names) and phuryn (last two path segments) both split one
|
|
7
|
+
* repository into several.
|
|
8
|
+
*
|
|
9
|
+
* Resolution depends on the filesystem today, not only on the logs, so results are cached in the
|
|
10
|
+
* `repositories` table, outside the tables that must rebuild identically from raw lines. Because the
|
|
11
|
+
* filesystem changes (folders are renamed and deleted), every run re-checks whether each cached
|
|
12
|
+
* directory and its repository root still exist, and resolves again only when that changed (D-028).
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { basename, dirname } from "node:path";
|
|
17
|
+
import { transaction } from "../db/database.js";
|
|
18
|
+
/**
|
|
19
|
+
* Runs the real git binary.
|
|
20
|
+
* @param args - Arguments after `git`.
|
|
21
|
+
* @returns Exit code (127 if git can't start) and trimmed stdout.
|
|
22
|
+
*/
|
|
23
|
+
export function runGit(args) {
|
|
24
|
+
const result = spawnSync("git", args, { encoding: "utf8" });
|
|
25
|
+
return { exitCode: result.status ?? 127, stdout: (result.stdout ?? "").trim() };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Asks git for the repository containing an existing directory.
|
|
29
|
+
* @param dir - An existing directory.
|
|
30
|
+
* @param git - Git runner.
|
|
31
|
+
* @returns The repository root, or null when git finds no repository.
|
|
32
|
+
*/
|
|
33
|
+
export function gitRoot(dir, git) {
|
|
34
|
+
// --path-format=absolute (git 2.31+) avoids resolving a relative ".git" against the wrong base.
|
|
35
|
+
const result = git(["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
36
|
+
if (result.exitCode !== 0 || result.stdout === "") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// A normal repository's common dir is <root>/.git, shared by every worktree. A bare repository's
|
|
40
|
+
// common dir is the repository itself.
|
|
41
|
+
const common = result.stdout;
|
|
42
|
+
return basename(common) === ".git" ? dirname(common) : common;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolves a working directory to its repository.
|
|
46
|
+
* @param cwd - A working directory recorded in a log line.
|
|
47
|
+
* @param lastKnownRoot - The repository root git gave while the directory still existed, if any.
|
|
48
|
+
* @param git - Git runner.
|
|
49
|
+
* @param exists - Existence check, injectable for tests.
|
|
50
|
+
* @returns For an existing directory, git's repository root or `not_git`. For a missing one
|
|
51
|
+
* (D-028): the last-known root if given; otherwise the repository containing its nearest
|
|
52
|
+
* existing parent folder; otherwise no root.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveRepo(cwd, lastKnownRoot = null, git = runGit, exists = existsSync) {
|
|
55
|
+
if (exists(cwd)) {
|
|
56
|
+
const root = gitRoot(cwd, git);
|
|
57
|
+
return root === null ? { kind: "not_git" } : { kind: "repo", repoRoot: root };
|
|
58
|
+
}
|
|
59
|
+
// Git verified this root while the directory existed; that outranks any lookup from a parent.
|
|
60
|
+
if (lastKnownRoot !== null) {
|
|
61
|
+
return { kind: "missing", repoRoot: lastKnownRoot, via: "last_known" };
|
|
62
|
+
}
|
|
63
|
+
let parent = dirname(cwd);
|
|
64
|
+
// dirname of the filesystem root is the root itself, so the walk always ends.
|
|
65
|
+
while (!exists(parent) && dirname(parent) !== parent) {
|
|
66
|
+
parent = dirname(parent);
|
|
67
|
+
}
|
|
68
|
+
const root = exists(parent) ? gitRoot(parent, git) : null;
|
|
69
|
+
return root === null
|
|
70
|
+
? { kind: "missing", repoRoot: null, via: "none" }
|
|
71
|
+
: { kind: "missing", repoRoot: root, via: "parent" };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reports whether a cached row no longer matches the filesystem (D-028).
|
|
75
|
+
* @param row - The cached row.
|
|
76
|
+
* @param exists - Existence check.
|
|
77
|
+
* @returns True when the directory appeared or disappeared, or its root's existence changed.
|
|
78
|
+
*/
|
|
79
|
+
export function isStale(row, exists) {
|
|
80
|
+
if (exists(row.cwd) !== (row.kind !== "missing")) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return row.repo_root !== null && exists(row.repo_root) !== (row.root_exists === 1);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolves working directories seen in log lines that have no cached resolution, and resolves
|
|
87
|
+
* again any cached one the filesystem no longer matches.
|
|
88
|
+
* @param db - Open, migrated database with derived tables up to date.
|
|
89
|
+
* @param now - Clock for `resolved_at`.
|
|
90
|
+
* @param resolve - Resolution function, given the last-known root; defaults to {@link resolveRepo}.
|
|
91
|
+
* @param exists - Existence check for re-validation and `root_exists`, injectable for tests.
|
|
92
|
+
* @returns Number of working directories resolved by this call (new plus re-resolved).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveRepositories(db, now, resolve = (cwd, lastKnownRoot) => resolveRepo(cwd, lastKnownRoot), exists = existsSync) {
|
|
95
|
+
const pending = db
|
|
96
|
+
.prepare(`SELECT DISTINCT p.cwd FROM parsed_lines p
|
|
97
|
+
LEFT JOIN repositories r ON r.cwd = p.cwd
|
|
98
|
+
WHERE p.cwd IS NOT NULL AND r.cwd IS NULL
|
|
99
|
+
ORDER BY p.cwd`)
|
|
100
|
+
.all();
|
|
101
|
+
const stale = db
|
|
102
|
+
.prepare("SELECT cwd, kind, repo_root, root_exists, resolved_via FROM repositories ORDER BY cwd")
|
|
103
|
+
.all().filter((row) => isStale(row, exists));
|
|
104
|
+
const upsert = db.prepare(`INSERT INTO repositories (cwd, kind, repo_root, root_exists, resolved_via, resolved_at)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
106
|
+
ON CONFLICT (cwd) DO UPDATE SET kind = excluded.kind, repo_root = excluded.repo_root,
|
|
107
|
+
root_exists = excluded.root_exists, resolved_via = excluded.resolved_via,
|
|
108
|
+
resolved_at = excluded.resolved_at`);
|
|
109
|
+
/**
|
|
110
|
+
* Resolves one directory and stores the result.
|
|
111
|
+
* @param cwd - The working directory.
|
|
112
|
+
* @param lastKnownRoot - Its last git-verified root, if any.
|
|
113
|
+
*/
|
|
114
|
+
const store = (cwd, lastKnownRoot) => {
|
|
115
|
+
const resolution = resolve(cwd, lastKnownRoot);
|
|
116
|
+
const root = resolution.kind === "not_git" ? null : resolution.repoRoot;
|
|
117
|
+
upsert.run(cwd, resolution.kind, root, root === null ? null : exists(root) ? 1 : 0, resolution.kind === "missing" ? resolution.via : "git", now().toISOString());
|
|
118
|
+
};
|
|
119
|
+
transaction(db, () => {
|
|
120
|
+
for (const { cwd } of pending) {
|
|
121
|
+
store(cwd, null);
|
|
122
|
+
}
|
|
123
|
+
for (const row of stale) {
|
|
124
|
+
// A root counts as last known only if git gave it: directly, or carried from an earlier git answer.
|
|
125
|
+
const verified = row.resolved_via === "git" || row.resolved_via === "last_known";
|
|
126
|
+
store(row.cwd, verified ? row.repo_root : null);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return pending.length + stale.length;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=attribution.js.map
|