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
51 changes: 48 additions & 3 deletions apps/desktop/src/main/__tests__/project-management-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
import { createProjectCatalog, type ProjectCatalog } from '@maka/storage';
import {
createProjectManagementService,
Expand Down Expand Up @@ -67,6 +71,49 @@ test('owns Project selection and reversible lifecycle actions in Desktop', async
}
});

test('adding a nested folder selects that folder instead of the parent project', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-add-'));
const parentPath = join(base, 'parent-project');
const childPath = join(parentPath, 'child-project');
await mkdir(childPath, { recursive: true });
await execFileAsync('git', ['init', '--quiet'], { cwd: parentPath });

const selected: string[] = [];
let nextDirectory = parentPath;
let nextId = 0;
const catalog = createProjectCatalog(join(base, 'storage'), {
now: () => 1_000,
createId: () => `project-${++nextId}`,
});
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
catalog: managementCatalog(catalog),
chooseDirectory: async () => nextDirectory,
selection: {
currentSelection: async () => ({ projectId: undefined, path: parentPath }),
setSelection: (_projectId, path) => selected.push(path),
},
});

try {
const parent = await service.add();
assert.equal(parent.ok, true);
if (!parent.ok) return;
assert.equal(parent.path, await realpath(parentPath));

nextDirectory = childPath;
const child = await service.add();
assert.equal(child.ok, true);
if (!child.ok) return;
assert.notEqual(child.project.id, parent.project.id);
assert.equal(child.path, await realpath(childPath));
assert.equal(selected.at(-1), await realpath(childPath));
} finally {
catalog.close();
await rm(base, { recursive: true, force: true });
}
});

test('rejects malformed Project identities before catalog access', async () => {
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
Expand Down Expand Up @@ -117,9 +164,7 @@ test('does not silently replace a stale Project preference with another Project'
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
catalog: {
list: async () => [
{ id: 'other', name: 'Other', locations: [], available: true },
],
list: async () => [{ id: 'other', name: 'Other', locations: [], available: true }],
register: unexpected,
relink: unexpected,
rename: unexpected,
Expand Down
91 changes: 91 additions & 0 deletions packages/storage/src/__tests__/project-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createProjectCatalog as createProjectCatalogBase,
type ProjectCatalog,
ProjectUnavailableError,
ProjectPathMismatchError,
type ResolvedProjectLocation,
resolveProjectLocation,
} from '../project-catalog.js';
Expand Down Expand Up @@ -116,6 +117,96 @@ test('a repository and its linked worktree resolve to one project identity', asy
}
});

test('registering a nested folder keeps that folder instead of the enclosing repository', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-folder-'));
try {
const parent = join(base, 'parent-project');
const child = join(parent, 'child-project');
await mkdir(child, { recursive: true });
await execFileAsync('git', ['init', '--quiet'], { cwd: parent });
const catalog = createProjectCatalog(join(base, 'storage'), {
now: () => 1_000,
createId: (() => {
let id = 0;
return () => `project-${++id}`;
})(),
});

const parentProject = await catalog.register(parent);
const childProject = await catalog.register(child);
const parentPath = await realpath(parent);
const childPath = await realpath(child);

assert.notEqual(childProject.id, parentProject.id);
assert.equal(parentProject.preferredPath, parentPath);
assert.equal(childProject.preferredPath, childPath);
assert.equal(childProject.name, 'child-project');

// session.create → HostWorkspaceResolver.touch(projectId, preferredPath)
const touched = await catalog.touch(childProject.id, childProject.preferredPath);
assert.equal(touched.id, childProject.id);
assert.equal(touched.preferredPath, childPath);
await assert.rejects(
() => catalog.touch(childProject.id, parentPath),
(error) => error instanceof ProjectPathMismatchError && error.projectId === childProject.id,
);
} finally {
await rm(base, { recursive: true, force: true });
}
});

test('relink and relinkWithSessions keep a nested repository directory', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-relink-'));
const storage = join(base, 'storage');
const sessions = createSessionStore(storage);
try {
const parent = join(base, 'parent-project');
const child = join(parent, 'child-project');
const childTwo = join(parent, 'child-two');
const elsewhere = join(base, 'elsewhere');
const elsewhereTwo = join(base, 'elsewhere-two');
await mkdir(child, { recursive: true });
await mkdir(childTwo, { recursive: true });
await mkdir(elsewhere, { recursive: true });
await mkdir(elsewhereTwo, { recursive: true });
await execFileAsync('git', ['init', '--quiet'], { cwd: parent });
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
createId: (() => {
let id = 0;
return () => `project-${++id}`;
})(),
});

const parentProject = await catalog.register(parent);
const original = await catalog.register(elsewhere);
const originalSessions = await catalog.register(elsewhereTwo);
const childPath = await realpath(child);
const childTwoPath = await realpath(childTwo);
const assigned = await sessions.create(sessionInput(elsewhereTwo, originalSessions.id));

const relinked = await catalog.relink(original.id, child);
assert.equal(relinked.id, original.id);
assert.notEqual(relinked.id, parentProject.id);
assert.equal(relinked.preferredPath, childPath);

const { project: relinkedSessions, updatedSessionIds } = await catalog.relinkWithSessions(
originalSessions.id,
childTwo,
);
assert.equal(relinkedSessions.id, originalSessions.id);
assert.notEqual(relinkedSessions.id, parentProject.id);
assert.equal(relinkedSessions.preferredPath, childTwoPath);
assert.deepEqual(updatedSessionIds, [assigned.id]);
const header = await sessions.readHeaderSnapshot(assigned.id);
assert.equal(header.projectId, relinkedSessions.id);
assert.equal(header.cwd, childTwoPath);
} finally {
await sessions.close?.();
await rm(base, { recursive: true, force: true });
}
});

async function resolveProjectLocationWithoutGit(path: string): Promise<ResolvedProjectLocation> {
const stdout = await runProjectCatalogWithoutGit(
'const [moduleUrl, path] = process.argv.slice(1); const { resolveProjectLocation } = await import(moduleUrl); console.log(JSON.stringify(await resolveProjectLocation({ path })));',
Expand Down
55 changes: 38 additions & 17 deletions packages/storage/src/project-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class SqliteProjectCatalog implements ProjectCatalog {
}

async register(path: string): Promise<ProjectRecord> {
const resolved = await resolveProjectLocation({ path });
const resolved = await resolveUserSelectedProjectLocation(path);
return this.upsertResolvedProject(resolved, this.now());
}

Expand Down Expand Up @@ -271,27 +271,24 @@ class SqliteProjectCatalog implements ProjectCatalog {
}

async touch(projectId: string, path?: string): Promise<ProjectRecord> {
let resolved: Awaited<ReturnType<typeof resolveProjectLocation>> | undefined;
try {
resolved = path ? await resolveProjectLocation({ path }) : undefined;
} catch {
throw new ProjectUnavailableError(projectId);
let canonicalPath: string | undefined;
if (path) {
try {
canonicalPath = normalize(await realpath(resolve(path)));
} catch {
throw new ProjectUnavailableError(projectId);
}
}
const resolvedPath = resolved
? resolved.kind === 'git'
? resolved.git!.worktreeRoot
: resolved.canonicalPath
: undefined;
const touched = await this.mutate((file) => {
const project = findProjectById(file.projects, projectId);
if (!project) throw new ProjectNotFoundError(projectId);
const location = resolvedPath
? project.locations.find((item) => item.path === resolvedPath)
const location = canonicalPath
? project.locations.find((item) => item.path === canonicalPath)
: [...project.locations].sort(
(a, b) => b.lastUsedAt - a.lastUsedAt || a.path.localeCompare(b.path),
)[0];
if (resolvedPath && !location) {
throw new ProjectPathMismatchError(projectId, resolvedPath);
if (canonicalPath && !location) {
throw new ProjectPathMismatchError(projectId, canonicalPath);
}
const timestamp = this.now();
if (location) location.lastUsedAt = timestamp;
Expand All @@ -302,7 +299,7 @@ class SqliteProjectCatalog implements ProjectCatalog {
}

async relink(projectId: string, path: string): Promise<ProjectRecord> {
const resolved = await resolveProjectLocation({ path });
const resolved = await resolveUserSelectedProjectLocation(path);
const timestamp = this.now();
const locationPath =
resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath;
Expand All @@ -322,7 +319,7 @@ class SqliteProjectCatalog implements ProjectCatalog {
projectId: string,
path: string,
): Promise<{ project: ProjectRecord; updatedSessionIds: readonly string[] }> {
const resolved = await resolveProjectLocation({ path });
const resolved = await resolveUserSelectedProjectLocation(path);
const timestamp = this.now();
const locationPath =
resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath;
Expand Down Expand Up @@ -835,6 +832,30 @@ export async function resolveProjectLocation(input: {
};
}

/**
* A directory the user picked in the add/relink chooser.
*
* `resolveProjectLocation` still walks to the enclosing Git worktree so a
* historical session cwd inside a repository stays on that repository.
* The chooser must not do that: selecting `repo/child` would otherwise
* silently become `repo` and reopen the parent project.
*/
async function resolveUserSelectedProjectLocation(path: string): Promise<ResolvedProjectLocation> {
const resolved = await resolveProjectLocation({ path });
if (
resolved.kind !== 'git' ||
!resolved.git ||
resolved.canonicalPath === resolved.git.worktreeRoot
) {
return resolved;
}
return {
canonicalPath: resolved.canonicalPath,
identity: `folder:${resolved.canonicalPath}`,
kind: 'folder',
};
}

async function resolveGitLocation(
canonicalPath: string,
): Promise<NonNullable<ResolvedProjectLocation['git']>> {
Expand Down
Loading