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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@superset/desktop",
"productName": "Superset",
"description": "The last developer tool you'll ever need",
"version": "0.0.8",
"version": "0.0.9",
"main": "./dist/main/index.js",
"resources": "src/resources",
"repository": {
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,54 @@ export async function checkNeedsRebase(
]);
return Number.parseInt(behindCount.trim(), 10) > 0;
}

/**
* Checks if a worktree has uncommitted changes (staged, unstaged, or untracked files)
* @param worktreePath - Path to the worktree
* @returns true if there are any uncommitted changes
*/
export async function hasUncommittedChanges(
worktreePath: string,
): Promise<boolean> {
const git = simpleGit(worktreePath);
const status = await git.status();
return !status.isClean();
}

/**
* Checks if a worktree has commits that haven't been pushed to the remote
* @param worktreePath - Path to the worktree
* @returns true if there are unpushed commits, false if all commits are pushed or no upstream exists
*/
export async function hasUnpushedCommits(
worktreePath: string,
): Promise<boolean> {
const git = simpleGit(worktreePath);
try {
// Count commits that are on HEAD but not on the upstream tracking branch
// @{upstream} refers to the configured upstream branch (e.g., origin/branch-name)
const aheadCount = await git.raw([
"rev-list",
"--count",
"@{upstream}..HEAD",
]);
return Number.parseInt(aheadCount.trim(), 10) > 0;
} catch {
// No upstream configured or other error - check if any commits exist at all
// that aren't on origin (for branches without tracking)
try {
// If there's no upstream, check if branch has commits not on any remote
const localCommits = await git.raw([
"rev-list",
"--count",
"HEAD",
"--not",
"--remotes",
]);
return Number.parseInt(localCommits.trim(), 10) > 0;
} catch {
// If all else fails, assume no unpushed commits
return false;
}
}
}
130 changes: 128 additions & 2 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,25 @@ mock.module("./utils/git", () => ({
import { createWorkspacesRouter } from "./workspaces";

// Helper to mock simple-git with specific worktree list output
function mockSimpleGitWithWorktreeList(worktreeListOutput: string) {
function mockSimpleGitWithWorktreeList(
worktreeListOutput: string,
options?: { isClean?: boolean; unpushedCommitCount?: number },
) {
const isClean = options?.isClean ?? true;
const unpushedCommitCount = options?.unpushedCommitCount ?? 0;
const mockGit = {
raw: mock(() => Promise.resolve(worktreeListOutput)),
raw: mock((args: string[]) => {
// Handle worktree list
if (args[0] === "worktree" && args[1] === "list") {
return Promise.resolve(worktreeListOutput);
}
// Handle rev-list for unpushed commits check
if (args[0] === "rev-list" && args[1] === "--count") {
return Promise.resolve(String(unpushedCommitCount));
}
return Promise.resolve("");
}),
status: mock(() => Promise.resolve({ isClean: () => isClean })),
};
mock.module("simple-git", () => ({
default: mock(() => mockGit),
Expand All @@ -82,6 +98,7 @@ function mockSimpleGitWithWorktreeList(worktreeListOutput: string) {
function mockSimpleGitWithError(error: Error) {
const mockGit = {
raw: mock(() => Promise.reject(error)),
status: mock(() => Promise.resolve({ isClean: () => true })),
};
mock.module("simple-git", () => ({
default: mock(() => mockGit),
Expand Down Expand Up @@ -207,4 +224,113 @@ describe("workspaces router - canDelete", () => {
"--porcelain",
]);
});

it("returns hasChanges: false when worktree is clean", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch",
{ isClean: true },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.hasChanges).toBe(false);
});

it("returns hasChanges: true when worktree has uncommitted changes", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch",
{ isClean: false },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.hasChanges).toBe(true);
});

it("returns hasChanges: false when worktree not found in git", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch",
{ isClean: false },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.warning).toContain("not found in git");
// hasChanges should be false when worktree doesn't exist
expect(result.hasChanges).toBe(false);
});

it("returns hasUnpushedCommits: false when all commits are pushed", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch",
{ isClean: true, unpushedCommitCount: 0 },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.hasUnpushedCommits).toBe(false);
});

it("returns hasUnpushedCommits: true when there are unpushed commits", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch",
{ isClean: true, unpushedCommitCount: 3 },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.hasUnpushedCommits).toBe(true);
});

it("returns hasUnpushedCommits: false when worktree not found in git", async () => {
mockSimpleGitWithWorktreeList(
"worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch",
{ isClean: true, unpushedCommitCount: 5 },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({ id: "workspace-1" });

expect(result.canDelete).toBe(true);
expect(result.warning).toContain("not found in git");
// hasUnpushedCommits should be false when worktree doesn't exist
expect(result.hasUnpushedCommits).toBe(false);
});

it("skips git checks when skipGitChecks is true", async () => {
const mockGit = mockSimpleGitWithWorktreeList(
"worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch",
{ isClean: false, unpushedCommitCount: 5 },
);

const router = createWorkspacesRouter();
const caller = router.createCaller({});
const result = await caller.canDelete({
id: "workspace-1",
skipGitChecks: true,
});

expect(result.canDelete).toBe(true);
// When skipping git checks, these should be false (defaults)
expect(result.hasChanges).toBe(false);
expect(result.hasUnpushedCommits).toBe(false);
// git.status should not have been called
expect(mockGit.status).not.toHaveBeenCalled();
});
});
40 changes: 39 additions & 1 deletion apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
generateBranchName,
getDefaultBranch,
hasOriginRemote,
hasUncommittedChanges,
hasUnpushedCommits,
removeWorktree,
worktreeExists,
} from "./utils/git";
Expand Down Expand Up @@ -288,7 +290,13 @@ export const createWorkspacesRouter = () => {
}),

canDelete: publicProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({
id: z.string(),
// Skip expensive git checks (status, unpushed) during polling - only check terminal count
skipGitChecks: z.boolean().optional(),
}),
)
.query(async ({ input }) => {
const workspace = db.data.workspaces.find((w) => w.id === input.id);

Expand All @@ -298,12 +306,28 @@ export const createWorkspacesRouter = () => {
reason: "Workspace not found",
workspace: null,
activeTerminalCount: 0,
hasChanges: false,
hasUnpushedCommits: false,
};
}

const activeTerminalCount =
terminalManager.getSessionCountByWorkspaceId(input.id);

// If skipping git checks, return early with just terminal count
// This is used during polling to avoid expensive git operations
if (input.skipGitChecks) {
return {
canDelete: true,
reason: null,
workspace,
warning: null,
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
};
}

const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
Expand All @@ -326,22 +350,34 @@ export const createWorkspacesRouter = () => {
warning:
"Worktree not found in git (may have been manually removed)",
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
};
}

// Check for uncommitted changes and unpushed commits in parallel
const [hasChanges, unpushedCommits] = await Promise.all([
hasUncommittedChanges(worktree.path),
hasUnpushedCommits(worktree.path),
]);

return {
canDelete: true,
reason: null,
workspace,
warning: null,
activeTerminalCount,
hasChanges,
hasUnpushedCommits: unpushedCommits,
};
} catch (error) {
return {
canDelete: false,
reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`,
workspace,
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
};
}
}
Expand All @@ -352,6 +388,8 @@ export const createWorkspacesRouter = () => {
workspace,
warning: "No associated worktree found",
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
};
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,36 @@ export function DeleteWorkspaceDialog({
}: DeleteWorkspaceDialogProps) {
const deleteWorkspace = useDeleteWorkspace();

// Query to check if workspace can be deleted
// Refetch every 2 seconds while dialog is open to keep terminal count fresh
const { data: canDeleteData, isLoading } = trpc.workspaces.canDelete.useQuery(
{ id: workspaceId },
// Initial query for git status (expensive) - only runs once when dialog opens
const { data: gitStatusData, isLoading: isLoadingGitStatus } =
trpc.workspaces.canDelete.useQuery(
{ id: workspaceId },
{
enabled: open,
staleTime: Number.POSITIVE_INFINITY, // Don't refetch automatically
},
);

// Polling query for terminal count only (cheap) - skips git checks
const { data: terminalCountData } = trpc.workspaces.canDelete.useQuery(
{ id: workspaceId, skipGitChecks: true },
{
enabled: open,
refetchInterval: open ? 2000 : false,
},
);

// Merge the data: use git status from initial query, terminal count from polling
const canDeleteData = gitStatusData
? {
...gitStatusData,
activeTerminalCount:
terminalCountData?.activeTerminalCount ??
gitStatusData.activeTerminalCount,
}
: terminalCountData;
const isLoading = isLoadingGitStatus;

const handleDelete = () => {
onOpenChange(false);

Expand Down Expand Up @@ -70,6 +90,8 @@ export function DeleteWorkspaceDialog({
const reason = canDeleteData?.reason;
const warning = canDeleteData?.warning;
const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0;
const hasChanges = canDeleteData?.hasChanges ?? false;
const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false;

return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
Expand All @@ -91,6 +113,16 @@ export function DeleteWorkspaceDialog({
Warning: {warning}
</span>
)}
{hasChanges && (
<span className="block mt-2 text-yellow-600 dark:text-yellow-400">
This workspace has uncommitted changes that will be lost.
</span>
)}
{hasUnpushedCommits && (
<span className="block mt-2 text-yellow-600 dark:text-yellow-400">
This workspace has unpushed commits that will be lost.
</span>
)}
{activeTerminalCount > 0 && (
<span className="block mt-2 text-muted-foreground">
{activeTerminalCount} active terminal
Expand Down
Loading