fix(server): reconcile workspace kind alongside project kind (#975)

The reconciler updated project.kind when a directory became a git repo
but never updated the workspace.kind underneath, leaving the sidebar in
a split-brain state: an expandable git project containing a workspace
with no leading icon (workspaceKind stayed "directory" instead of
flipping to "local_checkout" or "worktree").
This commit is contained in:
Mohamed Boudra
2026-05-13 15:58:48 +08:00
committed by GitHub
parent 9d7d1e8500
commit ea0bb81100
2 changed files with 100 additions and 26 deletions
@@ -114,6 +114,15 @@ function createWorkspaceGitServiceStub(
};
}
function initGitRepoInDir(dir: string): void {
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" });
}
function createTempGitRepo(prefix: string): string {
const raw = mkdtempSync(path.join(tmpdir(), prefix));
const dir = realpathSync(raw);
@@ -252,19 +261,7 @@ describe("WorkspaceReconciliationService", () => {
}),
);
// Initialize as git repo
execFileSync("git", ["init", "-b", "main"], { cwd: resolved, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: resolved,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: resolved, stdio: "ignore" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: resolved,
stdio: "ignore",
});
execFileSync("git", ["add", "."], { cwd: resolved, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "init"], { cwd: resolved, stdio: "ignore" });
initGitRepoInDir(resolved);
const service = new WorkspaceReconciliationService({
projectRegistry,
@@ -286,6 +283,59 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")!.kind).toBe("git");
});
test("updates workspace kind when a directory becomes a git repo", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "reconcile-ws-kind-"));
const resolved = realpathSync(dir);
tempDirs.push(resolved);
writeFileSync(path.join(resolved, "README.md"), "# Test\n");
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: resolved,
kind: "non_git",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: resolved,
kind: "directory",
displayName: path.basename(resolved),
createdAt: timestamp,
updatedAt: timestamp,
}),
);
initGitRepoInDir(resolved);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
workspaceGitService: createWorkspaceGitServiceStub({
[resolved]: {
projectKind: "git",
projectDisplayName: path.basename(resolved),
workspaceDisplayName: "main",
},
}),
});
await service.runOnce();
expect(projects.get("p1")!.kind).toBe("git");
expect(workspaces.get("w1")!.kind).toBe("local_checkout");
});
test("updates project display name when git remote changes", async () => {
const dir = createTempGitRepo("reconcile-remote-");
tempDirs.push(dir);
@@ -10,6 +10,15 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
function deriveWorkspaceKindFromMetadata(metadata: {
projectKind: "git" | "directory";
isWorktree: boolean;
}): PersistedWorkspaceRecord["kind"] {
if (metadata.projectKind !== "git") return "directory";
if (metadata.isWorktree) return "worktree";
return "local_checkout";
}
export type ReconciliationChange =
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
@@ -23,7 +32,7 @@ export type ReconciliationChange =
kind: "workspace_updated";
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">>;
};
export interface ReconciliationResult {
@@ -222,20 +231,35 @@ export class WorkspaceReconciliationService {
const wsDirName = workspace.cwd.split(/[\\/]/).findLast(Boolean) ?? workspace.cwd;
const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">> =
{};
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
displayName: wsGit.workspaceDisplayName,
updatedAt: timestamp,
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
fields: { displayName: wsGit.workspaceDisplayName },
});
workspaceUpdates.displayName = wsGit.workspaceDisplayName;
}
if (workspace.kind !== expectedKind) {
workspaceUpdates.kind = expectedKind;
}
if (Object.keys(workspaceUpdates).length === 0) {
return;
}
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
...workspaceUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
fields: workspaceUpdates,
});
}),
);
}