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
1 change: 1 addition & 0 deletions ix-cli/node_modules
28 changes: 24 additions & 4 deletions ix-cli/src/cli/__tests__/bootstrap-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,25 +86,45 @@ function readWorkspaceId(rootPath: string): string | undefined {
}

describe("workspace_id migration (Ix#225 gap 2)", () => {
it.skipIf(process.platform === "win32")("normalizes an equivalent registered root instead of duplicating it", () => {
const root = nodePath.join(home, "repoLinked");
const linked = nodePath.join(home, "repoAlias");
fs.mkdirSync(root, { recursive: true });
fs.symlinkSync(root, linked, "dir");
const canonicalRoot = fs.realpathSync.native(root);
const pathId = workspaceIdForPath(canonicalRoot);
writeConfig([{ workspace_id: pathId, workspace_name: "repoLinked", root_path: linked, default: true }]);

const state = ensureWorkspaceIdState(root);
const config = parse(fs.readFileSync(nodePath.join(home, ".ix", "config.yaml"), "utf8")) as {
workspaces: { root_path: string }[];
};

expect(state.migrated).toBe(false);
expect(config.workspaces).toHaveLength(1);
expect(config.workspaces[0].root_path).toBe(canonicalRoot);
});

it("re-keys a legacy random workspace_id to the path-based id and reports migrated", () => {
const root = nodePath.join(home, "repoOne");
fs.mkdirSync(root, { recursive: true });
writeConfig([{ workspace_id: "rand0001", workspace_name: "repoOne", root_path: root, default: true }]);

const pathId = workspaceIdForPath(root);
const canonicalRoot = fs.realpathSync.native(root);
const pathId = workspaceIdForPath(canonicalRoot);
expect(pathId).not.toBe("rand0001");

const state = ensureWorkspaceIdState(root);
expect(state.workspaceId).toBe(pathId);
expect(state.migrated).toBe(true);
expect(state.previousWorkspaceId).toBe("rand0001"); // captured for orphan cleanup
expect(readWorkspaceId(root)).toBe(pathId); // persisted
expect(readWorkspaceId(canonicalRoot)).toBe(pathId); // persisted
});

it("does NOT migrate (or churn) a workspace already on the path-based id", () => {
const root = nodePath.join(home, "repoTwo");
fs.mkdirSync(root, { recursive: true });
const pathId = workspaceIdForPath(root);
const pathId = workspaceIdForPath(fs.realpathSync.native(root));
writeConfig([{ workspace_id: pathId, workspace_name: "repoTwo", root_path: root, default: true }]);

const state = ensureWorkspaceIdState(root);
Expand All @@ -118,7 +138,7 @@ describe("workspace_id migration (Ix#225 gap 2)", () => {
writeConfig([]); // no workspaces yet

const state = ensureWorkspaceIdState(root);
expect(state.workspaceId).toBe(workspaceIdForPath(root));
expect(state.workspaceId).toBe(workspaceIdForPath(fs.realpathSync.native(root)));
expect(state.migrated).toBe(false);
});
});
171 changes: 171 additions & 0 deletions ix-cli/src/cli/__tests__/map-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { registerMapCommand } from "../commands/map.js";
import { canonicalMapRoot, resolveMapRoot } from "../map-root.js";
import { lockPathForTest } from "../single-flight.js";

const fixtures: string[] = [];
let savedHome: string | undefined;
let savedProfile: string | undefined;
let home: string;
let savedExitCode: number | string | undefined;

beforeEach(() => {
savedHome = process.env.HOME;
savedProfile = process.env.USERPROFILE;
savedExitCode = process.exitCode;
process.exitCode = undefined;
home = fixture();
process.env.HOME = home;
process.env.USERPROFILE = home;
mkdirSync(join(home, ".ix"), { recursive: true });
});

afterEach(() => {
process.env.HOME = savedHome;
process.env.USERPROFILE = savedProfile;
process.exitCode = savedExitCode;
vi.restoreAllMocks();
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true });
});

function fixture(): string {
const dir = mkdtempSync(join(tmpdir(), "ix-map-root-"));
fixtures.push(dir);
return dir;
}

describe("map root resolution", () => {
it("resolves an unregistered nested cwd to its git root", () => {
const root = fixture();
const nested = join(root, "src", "commands");
mkdirSync(nested, { recursive: true });
execFileSync("git", ["init", "-q"], { cwd: root });

expect(resolveMapRoot(undefined, nested)).toBe(realpathSync.native(root));
});

// `ix map` writes. A configured workspace outranking the repository the user
// is standing in means a bare `ix map` re-ingests a tree nothing on screen
// names -- and rewrites that workspace's map baseline on the way through.
it("maps the current repository, not the configured named workspace", () => {
const selected = fixture();
const repo = fixture();
const nested = join(repo, "src");
mkdirSync(nested, { recursive: true });
execFileSync("git", ["init", "-q"], { cwd: repo });
writeFileSync(join(home, ".ix", "config.yaml"), [
"endpoint: http://localhost:8090",
"workspace: selected",
"workspaces:",
" - workspace_id: selected-id",
" workspace_name: selected",
` root_path: ${selected}`,
" default: false",
"",
].join("\n"));

expect(resolveMapRoot(undefined, nested)).toBe(realpathSync.native(repo));
});

it("maps the current repository, not the default workspace", () => {
const selected = fixture();
const repo = fixture();
const nested = join(repo, "src");
mkdirSync(nested, { recursive: true });
execFileSync("git", ["init", "-q"], { cwd: repo });
writeFileSync(join(home, ".ix", "config.yaml"), [
"endpoint: http://localhost:8090",
"workspaces:",
" - workspace_id: selected-id",
" workspace_name: selected",
` root_path: ${selected}`,
" default: true",
"",
].join("\n"));

expect(resolveMapRoot(undefined, nested)).toBe(realpathSync.native(repo));
});

it("prefers the registered workspace containing cwd over its git root", () => {
const repo = fixture();
const registered = join(repo, "packages", "inner");
const nested = join(registered, "src");
mkdirSync(nested, { recursive: true });
execFileSync("git", ["init", "-q"], { cwd: repo });
writeFileSync(join(home, ".ix", "config.yaml"), [
"endpoint: http://localhost:8090",
"workspaces:",
" - workspace_id: inner-id",
" workspace_name: inner",
` root_path: ${registered}`,
" default: true",
"",
].join("\n"));

expect(resolveMapRoot(undefined, nested)).toBe(realpathSync.native(registered));
});

it("falls back to the default workspace when cwd has no local context", () => {
const selected = fixture();
const bare = fixture();
writeFileSync(join(home, ".ix", "config.yaml"), [
"endpoint: http://localhost:8090",
"workspaces:",
" - workspace_id: selected-id",
" workspace_name: selected",
` root_path: ${selected}`,
" default: true",
"",
].join("\n"));

expect(resolveMapRoot(undefined, bare)).toBe(realpathSync.native(selected));
});

it.skipIf(process.platform === "win32")("canonicalizes a symlink before deriving workspace identity", () => {
const root = fixture();
const real = join(root, "real");
const linked = join(root, "linked");
mkdirSync(real);
symlinkSync(real, linked, "dir");

expect(canonicalMapRoot(linked)).toBe(realpathSync.native(real));
expect(lockPathForTest(linked)).toBe(lockPathForTest(real));
});

it("rejects a missing path before bootstrap can register it", () => {
const root = fixture();
const missing = join(root, "missing");

expect(() => resolveMapRoot(missing, root)).toThrow(`Map path does not exist: ${missing}`);
});

it("reports a missing map path as structured json", async () => {
const root = fixture();
const missing = join(root, "missing");
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => output.push(args.join(" ")));
const program = new Command();
registerMapCommand(program);

await program.parseAsync(["node", "ix", "map", missing, "--format", "json"]);

expect(JSON.parse(output.join("\n"))).toEqual({
error: "invalid_map_path",
message: `Map path does not exist: ${missing}`,
});
expect(process.exitCode).toBe(1);
});

it("rejects a file path before bootstrap can register it", () => {
const root = fixture();
const file = join(root, "file.ts");
writeFileSync(file, "export {};\n");

expect(() => canonicalMapRoot(file)).toThrow(`Map path is not a directory: ${file}`);
});
});
9 changes: 6 additions & 3 deletions ix-cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { execFileSync } from "node:child_process";
import chalk from "chalk";
import { IxClient } from "../client/api.js";
import { getEndpoint, loadConfig, saveConfig, findWorkspaceForCwd, getDefaultWorkspace, type WorkspaceConfig } from "./config.js";
import { canonicalWorkspacePath, getEndpoint, loadConfig, saveConfig, findWorkspaceForCwd, getDefaultWorkspace, type WorkspaceConfig } from "./config.js";
import { workspaceIdForPath } from "./system.js";
import { readBackendHealth } from "./commands/upgrade.js";

Expand Down Expand Up @@ -48,11 +48,13 @@ interface WorkspaceState { ws: WorkspaceConfig; created: boolean; migrated: bool
* Returns the workspace name. Does nothing if already registered.
*/
function getOrCreateWorkspace(cwd: string): WorkspaceState {
const rootPath = resolve(cwd);
const rootPath = canonicalWorkspacePath(resolve(cwd));
const config = loadConfig();
const pathId = workspaceIdForPath(rootPath);
const existing = (config.workspaces ?? []).find(w => w.root_path === rootPath);
const existing = (config.workspaces ?? []).find(w => canonicalWorkspacePath(w.root_path) === rootPath);
if (existing) {
const pathChanged = existing.root_path !== rootPath;
existing.root_path = rootPath;
// Migrate a legacy random workspace_id to the path-based id (Ix#225 gap 2) so
// an already-registered repo converges with co-ingest. This changes the
// workspace_id that node identity folds, so the next map must re-ingest under
Expand All @@ -64,6 +66,7 @@ function getOrCreateWorkspace(cwd: string): WorkspaceState {
migratedRootsThisRun.set(rootPath, previousWorkspaceId);
return { ws: existing, created: false, migrated: true, previousWorkspaceId };
}
if (pathChanged) saveConfig(config);
const previousWorkspaceId = migratedRootsThisRun.get(rootPath);
return { ws: existing, created: false, migrated: previousWorkspaceId !== undefined, previousWorkspaceId };
}
Expand Down
18 changes: 16 additions & 2 deletions ix-cli/src/cli/commands/map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { resolve } from "node:path";
import { type Command } from "commander";
import chalk from "chalk";
import { IxClient } from "../../client/api.js";
Expand All @@ -14,6 +13,7 @@ import { acquireMapLock } from "../single-flight.js";
import { canRenderProgress } from "../stderr.js";
import { loadIngestBaseline } from "../ingest-baseline.js";
import { saveMapBaseline } from "../map-baseline.js";
import { resolveMapRoot } from "../map-root.js";

// Hard wall-clock budget for a single `ix map`. Past this, the shared deadline
// signal aborts every in-flight request and the command exits, so a single
Expand Down Expand Up @@ -346,7 +346,21 @@ Examples:
ix map . --full --verbose`
)
.action(async (pathArg: string | undefined, opts: { format: string; level?: string; minConfidence: string; maxItems: string; allItems?: boolean; sort: string; graph?: boolean; list?: boolean; full?: boolean; verbose?: boolean; silent?: boolean }) => {
const cwd = pathArg ? resolve(pathArg) : process.cwd();
let cwd: string;
try {
cwd = resolveMapRoot(pathArg);
} catch (err: any) {
const message = err?.message ?? "Invalid map path";
if (opts.format === "json") {
console.log(JSON.stringify({ error: "invalid_map_path", message }, null, 2));
} else if (opts.format === "llm") {
console.log(llmError("invalid_map_path", message));
} else {
console.error(chalk.red("Error:"), message);
}
process.exitCode = 1;
return;
}

const silent = opts.silent === true || opts.format === "silent";

Expand Down
39 changes: 31 additions & 8 deletions ix-cli/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync, rmSync, chmodSync, renameSync,
import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { parse, stringify } from "yaml";
import { IxClient } from "../client/api.js";

Expand Down Expand Up @@ -223,6 +223,12 @@ export function isPathInside(root: string, candidate: string): boolean {
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}

export function canonicalWorkspacePath(input: string): string {
const resolved = resolvePath(input);
try { return realpathSync.native(resolved); }
catch { return resolved; }
}

/**
* Is a path contained by one specific root after resolving symlinks on both
* sides? This is stricter than `isReadablePath`, which deliberately allows all
Expand Down Expand Up @@ -267,9 +273,11 @@ export function selectWorkspaceForCwd(
workspaces: WorkspaceConfig[],
cwd: string,
): WorkspaceConfig | undefined {
const canonicalCwd = canonicalWorkspacePath(cwd);
return workspaces
.filter(workspace => isPathInside(workspace.root_path, cwd))
.sort((a, b) => b.root_path.length - a.root_path.length)[0];
.map(workspace => ({ workspace, root: canonicalWorkspacePath(workspace.root_path) }))
.filter(({ root }) => isPathInside(root, canonicalCwd))
.sort((a, b) => b.root.length - a.root.length)[0]?.workspace;
}

export function findWorkspaceForCwd(cwd: string): WorkspaceConfig | undefined {
Expand Down Expand Up @@ -309,11 +317,10 @@ export function absoluteFromSourceUri(sourceUri: string, explicitRoot?: string):
return resolvePath(root, normalized);
}

export function resolveWorkspaceRoot(explicitRoot?: string): string {
export function resolveWorkspaceRoot(explicitRoot?: string, cwd = process.cwd()): string {
// 1. Explicit --root
if (explicitRoot) return explicitRoot;
// 2. Nearest initialized workspace containing cwd
const cwd = process.cwd();
const nearest = findWorkspaceForCwd(cwd);
if (nearest) return nearest.root_path;
// 3. Named workspace from `ix config set workspace <name>`
Expand All @@ -326,9 +333,25 @@ export function resolveWorkspaceRoot(explicitRoot?: string): string {
const defaultWs = getDefaultWorkspace();
if (defaultWs) return defaultWs.root_path;
// 5. Git root
try {
return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
} catch {}
const gitRoot = gitRootFor(cwd);
if (gitRoot) return gitRoot;
// 6. cwd fallback
return cwd;
}

/**
* The git top-level containing `cwd`, or undefined outside a repository.
*
* stderr is discarded: outside a repo git writes "fatal: not a git repository"
* to it, and this is a probe, not a failure the user needs to see.
*/
export function gitRootFor(cwd: string): string | undefined {
try {
const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || undefined;
} catch { return undefined; }
}
Loading
Loading