Skip to content

Commit 81bd27c

Browse files
authored
🤖 fix: allow non-git directories and clarify trunk branch requirement (#1068)
## Summary Two issues addressed: ### 1. Trunk branch warning was confusing on local workspaces Local runtime doesn't use trunk branch at all (it works directly in the project directory). The trunk branch selector was already hidden for local runtime, but the backend still required a trunk branch value - this led to confusing validation errors. **Fix:** Made `trunkBranch` optional in the API. Backend now only validates trunk branch for worktree/SSH runtimes that actually need it. ### 2. Git repo requirement was unnecessary Users should be able to add any directory as a project, even if it's not a git repository. Non-git directories are perfectly valid for local runtime which just runs Claude in the directory. **Fix:** - Removed git repository validation from `validateProjectPath` - Added `isGitRepository` helper to check git status separately - `listBranches` returns empty array (not error) for non-git repos - Runtime selector disables worktree/SSH with tooltip "Requires git repository" - UI auto-selects local runtime for non-git directories ### Changes | File | Change | |------|--------| | `src/common/orpc/schemas/api.ts` | Make `trunkBranch` optional | | `src/common/orpc/schemas/message.ts` | `recommendedTrunk` nullable | | `src/node/utils/pathUtils.ts` | Remove git requirement, add `isGitRepository` helper | | `src/node/services/projectService.ts` | Return empty branches for non-git | | `src/node/services/workspaceService.ts` | Trunk branch validation only for worktree/SSH | | `src/browser/components/RuntimeIconSelector.tsx` | New `disabledModes` prop with tooltip | | `src/browser/components/ChatInput/CreationControls.tsx` | Force local runtime for non-git | ### Testing - Updated `pathUtils.test.ts` - validates non-git directories pass - Updated `projectCreate.test.ts` - validates non-git projects work with local runtime - All existing tests continue to pass _Generated with `mux`_
1 parent e2170ba commit 81bd27c

File tree

15 files changed

+220
-76
lines changed

15 files changed

+220
-76
lines changed

‎docs/system-prompt.mdx‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,39 @@ When the user asks you to remember something:
4646

4747
/**
4848
* Build environment context XML block describing the workspace.
49+
* @param workspacePath - Workspace directory path
50+
* @param runtimeType - Runtime type: "local", "worktree", or "ssh"
4951
*/
50-
function buildEnvironmentContext(workspacePath: string): string {
52+
function buildEnvironmentContext(
53+
workspacePath: string,
54+
runtimeType: "local" | "worktree" | "ssh"
55+
): string {
56+
if (runtimeType === "local") {
57+
// Local runtime works directly in project directory - may or may not be git
58+
return `
59+
<environment>
60+
You are working in a directory at ${workspacePath}
61+
62+
- Tools run here automatically
63+
- You are meant to do your work isolated from the user and other agents
64+
</environment>
65+
`;
66+
}
67+
68+
if (runtimeType === "ssh") {
69+
// SSH runtime clones the repository on a remote host
70+
return `
71+
<environment>
72+
You are in a clone of a git repository at ${workspacePath}
73+
74+
- This IS a git repository - run git commands directly (no cd needed)
75+
- Tools run here automatically
76+
- You are meant to do your work isolated from the user and other agents
77+
</environment>
78+
`;
79+
}
80+
81+
// Worktree runtime creates a git worktree locally
5182
return `
5283
<environment>
5384
You are in a git worktree at ${workspacePath}

‎src/browser/App.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -318,16 +318,17 @@ function AppInner() {
318318
const getBranchesForProject = useCallback(
319319
async (projectPath: string): Promise<BranchListResult> => {
320320
if (!api) {
321-
return { branches: [], recommendedTrunk: "" };
321+
return { branches: [], recommendedTrunk: null };
322322
}
323323
const branchResult = await api.projects.listBranches({ projectPath });
324324
const sanitizedBranches = branchResult.branches.filter(
325325
(branch): branch is string => typeof branch === "string"
326326
);
327327

328-
const recommended = sanitizedBranches.includes(branchResult.recommendedTrunk)
329-
? branchResult.recommendedTrunk
330-
: (sanitizedBranches[0] ?? "");
328+
const recommended =
329+
branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk)
330+
? branchResult.recommendedTrunk
331+
: (sanitizedBranches[0] ?? null);
331332

332333
return {
333334
branches: sanitizedBranches,

‎src/browser/components/ChatInput/CreationControls.tsx‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback } from "react";
1+
import React, { useCallback, useEffect } from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
33
import { Select } from "../Select";
44
import { RuntimeIconSelector } from "../RuntimeIconSelector";
@@ -9,6 +9,8 @@ import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
99

1010
interface CreationControlsProps {
1111
branches: string[];
12+
/** Whether branches have finished loading (to distinguish loading vs non-git repo) */
13+
branchesLoaded: boolean;
1214
trunkBranch: string;
1315
onTrunkBranchChange: (branch: string) => void;
1416
runtimeMode: RuntimeMode;
@@ -25,15 +27,26 @@ interface CreationControlsProps {
2527
/**
2628
* Additional controls shown only during workspace creation
2729
* - Trunk branch selector (which branch to fork from) - hidden for Local runtime
28-
* - Runtime mode (Local, Worktree, SSH)
30+
* - Runtime mode (Local, Worktree, SSH) - only Local available for non-git directories
2931
* - Workspace name (auto-generated with manual override)
3032
*/
3133
export function CreationControls(props: CreationControlsProps) {
34+
// Non-git directories (empty branches after loading completes) can only use local runtime
35+
// Don't check until branches have loaded to avoid prematurely switching runtime
36+
const isNonGitRepo = props.branchesLoaded && props.branches.length === 0;
37+
3238
// Local runtime doesn't need a trunk branch selector (uses project dir as-is)
3339
const showTrunkBranchSelector =
3440
props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL;
3541

36-
const { nameState } = props;
42+
const { runtimeMode, onRuntimeModeChange, nameState } = props;
43+
44+
// Force local runtime for non-git directories (only after branches loaded)
45+
useEffect(() => {
46+
if (isNonGitRepo && runtimeMode !== RUNTIME_MODE.LOCAL) {
47+
onRuntimeModeChange(RUNTIME_MODE.LOCAL);
48+
}
49+
}, [isNonGitRepo, runtimeMode, onRuntimeModeChange]);
3750

3851
const handleNameChange = useCallback(
3952
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -121,6 +134,7 @@ export function CreationControls(props: CreationControlsProps) {
121134
defaultMode={props.defaultRuntimeMode}
122135
onSetDefault={props.onSetDefaultRuntime}
123136
disabled={props.disabled}
137+
disabledModes={isNonGitRepo ? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH] : undefined}
124138
/>
125139

126140
{/* Trunk Branch Selector - hidden for Local runtime */}

‎src/browser/components/ChatInput/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
16051605
{variant === "creation" && (
16061606
<CreationControls
16071607
branches={creationState.branches}
1608+
branchesLoaded={creationState.branchesLoaded}
16081609
trunkBranch={creationState.trunkBranch}
16091610
onTrunkBranchChange={creationState.setTrunkBranch}
16101611
runtimeMode={creationState.runtimeMode}

‎src/browser/components/ChatInput/useCreationWorkspace.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
5858

5959
interface UseCreationWorkspaceReturn {
6060
branches: string[];
61+
/** Whether listBranches has completed (to distinguish loading vs non-git repo) */
62+
branchesLoaded: boolean;
6163
trunkBranch: string;
6264
setTrunkBranch: (branch: string) => void;
6365
runtimeMode: RuntimeMode;
@@ -94,6 +96,7 @@ export function useCreationWorkspace({
9496
}: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn {
9597
const { api } = useAPI();
9698
const [branches, setBranches] = useState<string[]>([]);
99+
const [branchesLoaded, setBranchesLoaded] = useState(false);
97100
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
98101
const [toast, setToast] = useState<Toast | null>(null);
99102
const [isSending, setIsSending] = useState(false);
@@ -133,13 +136,16 @@ export function useCreationWorkspace({
133136
if (!projectPath.length || !api) {
134137
return;
135138
}
139+
setBranchesLoaded(false);
136140
const loadBranches = async () => {
137141
try {
138142
const result = await api.projects.listBranches({ projectPath });
139143
setBranches(result.branches);
140144
setRecommendedTrunk(result.recommendedTrunk);
141145
} catch (err) {
142146
console.error("Failed to load branches:", err);
147+
} finally {
148+
setBranchesLoaded(true);
143149
}
144150
};
145151
void loadBranches();
@@ -249,6 +255,7 @@ export function useCreationWorkspace({
249255

250256
return {
251257
branches,
258+
branchesLoaded,
252259
trunkBranch: settings.trunkBranch,
253260
setTrunkBranch,
254261
runtimeMode: settings.runtimeMode,

‎src/browser/components/RuntimeIconSelector.tsx‎

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ interface RuntimeIconSelectorProps {
1212
/** Called when user checks "Default for project" in tooltip */
1313
onSetDefault: (mode: RuntimeMode) => void;
1414
disabled?: boolean;
15+
/** Modes that cannot be selected (e.g., worktree/SSH for non-git repos) */
16+
disabledModes?: RuntimeMode[];
1517
className?: string;
1618
}
1719

@@ -58,6 +60,8 @@ interface RuntimeIconButtonProps {
5860
onClick: () => void;
5961
onSetDefault: () => void;
6062
disabled?: boolean;
63+
/** Why this mode is unavailable (shown in tooltip when disabled) */
64+
unavailableReason?: string;
6165
}
6266

6367
function RuntimeIconButton(props: RuntimeIconButtonProps) {
@@ -98,15 +102,19 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
98102
>
99103
<strong>{info.label}</strong>
100104
<p className="text-muted mt-0.5 text-xs">{info.description}</p>
101-
<label className="mt-1.5 flex cursor-pointer items-center gap-1.5 text-xs">
102-
<input
103-
type="checkbox"
104-
checked={props.isDefault}
105-
onChange={() => props.onSetDefault()}
106-
className="accent-accent h-3 w-3"
107-
/>
108-
<span className="text-muted">Default for project</span>
109-
</label>
105+
{props.unavailableReason ? (
106+
<p className="mt-1 text-xs text-yellow-500">{props.unavailableReason}</p>
107+
) : (
108+
<label className="mt-1.5 flex cursor-pointer items-center gap-1.5 text-xs">
109+
<input
110+
type="checkbox"
111+
checked={props.isDefault}
112+
onChange={() => props.onSetDefault()}
113+
className="accent-accent h-3 w-3"
114+
/>
115+
<span className="text-muted">Default for project</span>
116+
</label>
117+
)}
110118
</TooltipContent>
111119
</Tooltip>
112120
);
@@ -120,24 +128,29 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
120128
*/
121129
export function RuntimeIconSelector(props: RuntimeIconSelectorProps) {
122130
const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH];
131+
const disabledModes = props.disabledModes ?? [];
123132

124133
return (
125134
<div
126135
className={cn("inline-flex items-center gap-1", props.className)}
127136
data-component="RuntimeIconSelector"
128137
data-tutorial="runtime-selector"
129138
>
130-
{modes.map((mode) => (
131-
<RuntimeIconButton
132-
key={mode}
133-
mode={mode}
134-
isSelected={props.value === mode}
135-
isDefault={props.defaultMode === mode}
136-
onClick={() => props.onChange(mode)}
137-
onSetDefault={() => props.onSetDefault(mode)}
138-
disabled={props.disabled}
139-
/>
140-
))}
139+
{modes.map((mode) => {
140+
const isModeDisabled = disabledModes.includes(mode);
141+
return (
142+
<RuntimeIconButton
143+
key={mode}
144+
mode={mode}
145+
isSelected={props.value === mode}
146+
isDefault={props.defaultMode === mode}
147+
onClick={() => props.onChange(mode)}
148+
onSetDefault={() => props.onSetDefault(mode)}
149+
disabled={Boolean(props.disabled) || isModeDisabled}
150+
unavailableReason={isModeDisabled ? "Requires git repository" : undefined}
151+
/>
152+
);
153+
})}
141154
</div>
142155
);
143156
}

‎src/browser/contexts/ProjectContext.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export function ProjectProvider(props: { children: ReactNode }) {
172172
setWorkspaceModalState((prev) => ({
173173
...prev,
174174
branches,
175-
defaultTrunkBranch: recommendedTrunk,
175+
defaultTrunkBranch: recommendedTrunk ?? undefined,
176176
loadErrorMessage: null,
177177
isLoading: false,
178178
}));

‎src/common/orpc/schemas/api.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ export const workspace = {
179179
input: z.object({
180180
projectPath: z.string(),
181181
branchName: z.string(),
182-
trunkBranch: z.string(),
182+
/** Trunk branch to fork from - only required for worktree/SSH runtimes, ignored for local */
183+
trunkBranch: z.string().optional(),
183184
/** Human-readable title (e.g., "Fix plan mode over SSH") - optional for backwards compat */
184185
title: z.string().optional(),
185186
runtimeConfig: RuntimeConfigSchema.optional(),

‎src/common/orpc/schemas/message.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,6 @@ export const MuxMessageSchema = z.object({
9797

9898
export const BranchListResultSchema = z.object({
9999
branches: z.array(z.string()),
100-
recommendedTrunk: z.string(),
100+
/** Recommended trunk branch, or null for non-git directories */
101+
recommendedTrunk: z.string().nullable(),
101102
});

‎src/node/services/projectService.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Config, ProjectConfig } from "@/node/config";
2-
import { validateProjectPath } from "@/node/utils/pathUtils";
2+
import { validateProjectPath, isGitRepository } from "@/node/utils/pathUtils";
33
import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git";
44
import type { Result } from "@/common/types/result";
55
import { Ok, Err } from "@/common/types/result";
@@ -138,6 +138,12 @@ export class ProjectService {
138138
throw new Error(validation.error ?? "Invalid project path");
139139
}
140140
const normalizedPath = validation.expandedPath!;
141+
142+
// Non-git repos return empty branches - they're restricted to local runtime only
143+
if (!(await isGitRepository(normalizedPath))) {
144+
return { branches: [], recommendedTrunk: null };
145+
}
146+
141147
const branches = await listLocalBranches(normalizedPath);
142148
const recommendedTrunk = await detectDefaultTrunkBranch(normalizedPath, branches);
143149
return { branches, recommendedTrunk };

0 commit comments

Comments
 (0)