Skip to content

Commit 604fd11

Browse files
committed
feat: add per-channel base branch setting
1 parent ccee784 commit 604fd11

File tree

11 files changed

+149
-13
lines changed

11 files changed

+149
-13
lines changed

packages/config/dashboard-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type DashboardConfig = {
4141
agentProvider?: "opencode" | "claudecode" | "codex" | "kimi";
4242
model: string;
4343
workingDirectory: string;
44+
baseBranch: string;
4445
channelSystemMessage?: string;
4546
}[];
4647
}[];
@@ -79,6 +80,11 @@ const cloneDefaultDashboardConfig = (): DashboardConfig => structuredClone(defau
7980
const asString = (value: unknown, fallback = "") =>
8081
typeof value === "string" ? value : fallback;
8182

83+
const asBaseBranch = (value: unknown) => {
84+
const normalized = asString(value).trim();
85+
return normalized.length > 0 ? normalized : "main";
86+
};
87+
8288
const asNumber = (value: unknown, fallback = 0) =>
8389
typeof value === "number" && Number.isFinite(value) ? value : fallback;
8490

@@ -124,6 +130,7 @@ const sanitizeChannelDetail = (
124130
agentProvider: asAgentProvider(detail.agentProvider),
125131
model: asString(detail.model),
126132
workingDirectory: asString(detail.workingDirectory),
133+
baseBranch: asBaseBranch(detail.baseBranch),
127134
channelSystemMessage: asString(detail.channelSystemMessage),
128135
};
129136
};

packages/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ export {
2424
getGitHubInfoForUser,
2525
resolveChannelCwd,
2626
getChannelSystemMessage,
27+
getChannelBaseBranch,
2728
setChannelCwd,
2829
setChannelWorkingDirectory,
30+
setChannelBaseBranch,
2931
setChannelSystemMessage,
3032
setGitHubInfoForUser,
3133
clearGitHubInfoForUser,

packages/config/local/ode.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const channelDetailSchema = z.object({
7070
),
7171
model: z.string().optional().default(""),
7272
workingDirectory: z.string().optional().default(""),
73+
baseBranch: z.string().optional().default("main"),
7374
channelSystemMessage: z.string().optional().default(""),
7475
});
7576

@@ -163,6 +164,11 @@ function ensureConfigFile(): void {
163164
writeFileSync(ODE_CONFIG_FILE, JSON.stringify(EMPTY_TEMPLATE, null, 2));
164165
}
165166

167+
function normalizeBaseBranch(baseBranch: string | null | undefined): string {
168+
const normalized = baseBranch?.trim();
169+
return normalized && normalized.length > 0 ? normalized : "main";
170+
}
171+
166172
function normalizeConfig(config: OdeConfig): OdeConfig {
167173
const { defaultMessageFrequency: _deprecatedMessageFrequency, ...normalizedUser } = config.user;
168174
const statusMessageFormat = config.user.defaultStatusMessageFormat
@@ -189,6 +195,13 @@ function normalizeConfig(config: OdeConfig): OdeConfig {
189195
.map((model) => model.trim())
190196
.filter(Boolean)));
191197
const completeOnboarding = config.completeOnboarding === true;
198+
const workspaces = config.workspaces.map((workspace) => ({
199+
...workspace,
200+
channelDetails: workspace.channelDetails.map((channel) => ({
201+
...channel,
202+
baseBranch: normalizeBaseBranch(channel.baseBranch),
203+
})),
204+
}));
192205
return {
193206
...config,
194207
user: {
@@ -217,6 +230,7 @@ function normalizeConfig(config: OdeConfig): OdeConfig {
217230
},
218231
},
219232
completeOnboarding,
233+
workspaces,
220234
};
221235
}
222236

@@ -482,6 +496,18 @@ export function setChannelWorkingDirectory(channelId: string, workingDirectory:
482496
}));
483497
}
484498

499+
export function getChannelBaseBranch(channelId: string): string {
500+
return normalizeBaseBranch(getChannelDetails(channelId)?.baseBranch);
501+
}
502+
503+
export function setChannelBaseBranch(channelId: string, baseBranch: string | null): void {
504+
const normalized = normalizeBaseBranch(baseBranch);
505+
updateChannel(channelId, (channel) => ({
506+
...channel,
507+
baseBranch: normalized,
508+
}));
509+
}
510+
485511
export function getChannelSystemMessage(channelId: string): string | null {
486512
return getChannelDetails(channelId)?.channelSystemMessage ?? null;
487513
}

packages/core/onboarding.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ async function setupSlackWorkspaces(rl: Interface, config: OdeConfig): Promise<O
170170
channelDetails: discoveredWorkspace.channelDetails.map((channel) => ({
171171
...channel,
172172
agentProvider: channel.agentProvider ?? "opencode",
173+
baseBranch: channel.baseBranch?.trim() || "main",
173174
channelSystemMessage: channel.channelSystemMessage ?? "",
174175
})),
175176
};

packages/core/runtime/session-bootstrap.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { loadSession, saveSession, type PersistedSession } from "@/config/local/sessions";
2-
import { resolveChannelCwd, resolveGitStrategy } from "@/config";
2+
import { getChannelBaseBranch, resolveChannelCwd, resolveGitStrategy } from "@/config";
33
import { buildSessionEnvironment, prepareSessionWorkspace } from "@/core/session";
44
import { CoreStateMachine } from "@/core/state-machine";
55
import { categorizeRuntimeError } from "@/core/runtime/helpers";
@@ -62,11 +62,13 @@ export async function prepareRuntimeSession(params: {
6262
try {
6363
stateMachine.transition("prepare_worktree");
6464
const worktreeId = `ode_${threadId}`;
65+
const baseBranch = getChannelBaseBranch(channelId);
6566
const { cwd: resolvedCwd, worktree } = await prepareSessionWorkspace({
6667
channelId,
6768
threadId,
6869
cwd,
6970
worktreeId,
71+
baseBranch,
7072
sessionEnv,
7173
gitIdentity,
7274
});

packages/core/session.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,12 @@ export async function prepareSessionWorkspace(params: {
8989
threadId: string;
9090
cwd: string;
9191
worktreeId: string;
92+
baseBranch: string;
9293
sessionEnv: SessionEnvironment;
9394
gitIdentity: GitIdentity;
9495
}): Promise<PreparedWorkspace> {
95-
const { channelId, threadId, cwd, worktreeId, sessionEnv, gitIdentity } = params;
96-
const worktree = await ensureSessionWorktree({ cwd, worktreeId, env: sessionEnv });
96+
const { channelId, threadId, cwd, worktreeId, baseBranch, sessionEnv, gitIdentity } = params;
97+
const worktree = await ensureSessionWorktree({ cwd, worktreeId, baseBranch, env: sessionEnv });
9798
let resolvedCwd = cwd;
9899

99100
if (!worktree.skipped && worktree.worktreePath !== cwd) {

packages/core/web/local-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const buildDiscoveredChannelDetails = (
104104
agentProvider: "opencode",
105105
model: fallbackModel,
106106
workingDirectory: "",
107+
baseBranch: "main",
107108
channelSystemMessage: "",
108109
}));
109110

@@ -129,6 +130,7 @@ const buildSyncedChannelDetails = (
129130
agentProvider,
130131
model: existing?.model ?? (agentProvider === "opencode" || agentProvider === "codex" ? fallbackModel : ""),
131132
workingDirectory: existing?.workingDirectory ?? "",
133+
baseBranch: existing?.baseBranch?.trim() ? existing.baseBranch.trim() : "main",
132134
channelSystemMessage: existing?.channelSystemMessage ?? "",
133135
};
134136
});

packages/ims/slack/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ async function postSettingsLauncher(
255255
type: "section",
256256
text: {
257257
type: "mrkdwn",
258-
text: "Open channel settings for agent, working directory, and optional channel system message (model appears for OpenCode and Codex).",
258+
text: "Open channel settings for agent, working directory, base branch, and optional channel system message (model appears for OpenCode and Codex).",
259259
},
260260
},
261261
{

packages/ims/slack/commands.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
setChannelAgentProvider,
1212
setChannelModel,
1313
setChannelWorkingDirectory,
14+
getChannelBaseBranch,
15+
setChannelBaseBranch,
1416
getChannelSystemMessage,
1517
setChannelSystemMessage,
1618
setGitHubInfoForUser,
@@ -34,6 +36,8 @@ const MODEL_BLOCK = "model";
3436
const MODEL_ACTION = "model_select";
3537
const WORKING_DIR_BLOCK = "working_dir";
3638
const WORKING_DIR_ACTION = "working_dir_input";
39+
const BASE_BRANCH_BLOCK = "base_branch";
40+
const BASE_BRANCH_ACTION = "base_branch_input";
3741
const CHANNEL_SYSTEM_MESSAGE_BLOCK = "channel_system_message";
3842
const CHANNEL_SYSTEM_MESSAGE_ACTION = "channel_system_message_input";
3943

@@ -73,6 +77,7 @@ function buildSettingsModal(params: {
7377
selectedProvider?: AgentProvider;
7478
selectedModel?: string | null;
7579
workingDirectory?: string | null;
80+
baseBranch?: string | null;
7681
channelSystemMessage?: string | null;
7782
}) {
7883
const {
@@ -83,6 +88,7 @@ function buildSettingsModal(params: {
8388
selectedProvider = "opencode",
8489
selectedModel,
8590
workingDirectory,
91+
baseBranch,
8692
channelSystemMessage,
8793
} = params;
8894
const providerLabels: Record<AgentProvider, string> = {
@@ -115,10 +121,10 @@ function buildSettingsModal(params: {
115121
? matchedSelectedModel
116122
: (selectedProvider === "codex" ? "__default__" : (opencodeModels[0] ?? "__none__"));
117123
const introText = selectedProvider === "opencode"
118-
? "Configure agent, model (OpenCode), and working directory for this channel."
124+
? "Configure agent, model (OpenCode), working directory, and base branch for this channel."
119125
: selectedProvider === "codex"
120-
? "Configure agent, optional Codex model, and working directory for this channel."
121-
: "Configure agent and working directory for this channel.";
126+
? "Configure agent, optional Codex model, working directory, and base branch for this channel."
127+
: "Configure agent, working directory, and base branch for this channel.";
122128

123129
const blocks: any[] = [
124130
{
@@ -173,6 +179,19 @@ function buildSettingsModal(params: {
173179
},
174180
});
175181

182+
blocks.push({
183+
type: "input" as const,
184+
block_id: BASE_BRANCH_BLOCK,
185+
optional: true,
186+
label: { type: "plain_text" as const, text: "Base Branch" },
187+
element: {
188+
type: "plain_text_input" as const,
189+
action_id: BASE_BRANCH_ACTION,
190+
initial_value: baseBranch?.trim() || "main",
191+
placeholder: { type: "plain_text" as const, text: "e.g., main" },
192+
},
193+
});
194+
176195
blocks.push({
177196
type: "input" as const,
178197
block_id: CHANNEL_SYSTEM_MESSAGE_BLOCK,
@@ -289,6 +308,7 @@ export function setupInteractiveHandlers(): void {
289308
selectedProvider: toSelectableProvider(getChannelAgentProvider(channelId)),
290309
selectedModel: getChannelModel(channelId),
291310
workingDirectory: resolveChannelCwd(channelId).workingDirectory,
311+
baseBranch: getChannelBaseBranch(channelId),
292312
channelSystemMessage: getChannelSystemMessage(channelId),
293313
});
294314

@@ -353,6 +373,9 @@ export function setupInteractiveHandlers(): void {
353373
|| getChannelModel(channelId)
354374
|| undefined;
355375
const workingDirectory = view.state?.values?.[WORKING_DIR_BLOCK]?.[WORKING_DIR_ACTION]?.value || "";
376+
const baseBranch = view.state?.values?.[BASE_BRANCH_BLOCK]?.[BASE_BRANCH_ACTION]?.value
377+
|| getChannelBaseBranch(channelId)
378+
|| "main";
356379
const channelSystemMessage = view.state?.values?.[CHANNEL_SYSTEM_MESSAGE_BLOCK]?.[CHANNEL_SYSTEM_MESSAGE_ACTION]?.value
357380
|| getChannelSystemMessage(channelId)
358381
|| "";
@@ -365,6 +388,7 @@ export function setupInteractiveHandlers(): void {
365388
selectedProvider,
366389
selectedModel,
367390
workingDirectory,
391+
baseBranch,
368392
channelSystemMessage,
369393
});
370394

@@ -388,6 +412,7 @@ export function setupInteractiveHandlers(): void {
388412
: "opencode";
389413
const selectedModel = values?.[MODEL_BLOCK]?.[MODEL_ACTION]?.selected_option?.value;
390414
const workingDirectory = values?.[WORKING_DIR_BLOCK]?.[WORKING_DIR_ACTION]?.value || "";
415+
const baseBranch = values?.[BASE_BRANCH_BLOCK]?.[BASE_BRANCH_ACTION]?.value || "main";
391416
const channelSystemMessage = values?.[CHANNEL_SYSTEM_MESSAGE_BLOCK]?.[CHANNEL_SYSTEM_MESSAGE_ACTION]?.value || "";
392417

393418
const errors: Record<string, string> = {};
@@ -438,6 +463,7 @@ export function setupInteractiveHandlers(): void {
438463

439464
const workingDirValue = workingDirectory.trim();
440465
setChannelWorkingDirectory(channelId, workingDirValue.length > 0 ? workingDirValue : null);
466+
setChannelBaseBranch(channelId, baseBranch);
441467
setChannelSystemMessage(channelId, channelSystemMessage);
442468
} catch (err) {
443469
const userId = (body as any).user?.id;

packages/utils/worktree.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ function localBranchExists(repoRoot: string, branch: string, env?: Record<string
8989
}
9090
}
9191

92+
function remoteBranchExists(repoRoot: string, branch: string, env?: Record<string, string>): boolean {
93+
try {
94+
runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], repoRoot, env);
95+
return true;
96+
} catch {
97+
return false;
98+
}
99+
}
100+
92101
function ensureDir(path: string): void {
93102
if (!existsSync(path)) {
94103
mkdirSync(path, { recursive: true });
@@ -150,9 +159,11 @@ function ensureWorktreeConfig(repoRoot: string, env?: Record<string, string>): v
150159
export async function ensureSessionWorktree(params: {
151160
cwd: string;
152161
worktreeId: string;
162+
baseBranch: string;
153163
env?: Record<string, string>;
154164
}): Promise<WorktreeResult> {
155-
const { cwd, worktreeId, env } = params;
165+
const { cwd, worktreeId, baseBranch, env } = params;
166+
const normalizedBaseBranch = baseBranch.trim() || "main";
156167
const repoRoot = resolveRepoRoot(cwd, env);
157168
if (!repoRoot) {
158169
return { worktreePath: cwd, repoRoot: null, created: false, reused: false, skipped: true };
@@ -180,8 +191,8 @@ export async function ensureSessionWorktree(params: {
180191

181192
const currentBranch = getCurrentBranch(repoRoot, env);
182193
const dirtyPaths = getDirtyPaths(repoRoot, env);
183-
if (currentBranch === "main" && dirtyPaths.length > 0) {
184-
const message = "Main has uncommitted changes, skipping worktree and staying on main.";
194+
if (currentBranch === normalizedBaseBranch && dirtyPaths.length > 0) {
195+
const message = `${normalizedBaseBranch} has uncommitted changes, skipping worktree and staying on ${normalizedBaseBranch}.`;
185196
log.warn(message, { repoRoot });
186197
return {
187198
worktreePath: repoRoot,
@@ -193,15 +204,52 @@ export async function ensureSessionWorktree(params: {
193204
};
194205
}
195206

207+
let hasLocalBaseBranch = localBranchExists(repoRoot, normalizedBaseBranch, env);
208+
let hasRemoteBaseBranch = remoteBranchExists(repoRoot, normalizedBaseBranch, env);
209+
if (!hasLocalBaseBranch && !hasRemoteBaseBranch) {
210+
try {
211+
runGit(["fetch", "origin", normalizedBaseBranch], repoRoot, env);
212+
} catch {
213+
// Handle branch-not-found with a user-facing message below.
214+
}
215+
hasLocalBaseBranch = localBranchExists(repoRoot, normalizedBaseBranch, env);
216+
hasRemoteBaseBranch = remoteBranchExists(repoRoot, normalizedBaseBranch, env);
217+
}
218+
219+
if (!hasLocalBaseBranch && !hasRemoteBaseBranch) {
220+
const message = `Base branch '${normalizedBaseBranch}' not found in this repo. Open channel settings and update Base Branch.`;
221+
log.warn(message, { repoRoot, baseBranch: normalizedBaseBranch });
222+
return {
223+
worktreePath: repoRoot,
224+
repoRoot,
225+
created: false,
226+
reused: false,
227+
skipped: true,
228+
message,
229+
};
230+
}
231+
196232
ensureDir(worktreeDir);
197-
log.info("Pulling latest main before creating worktree", { repoRoot });
198-
runGit(["pull", "origin", "main"], repoRoot, env);
233+
if (currentBranch === normalizedBaseBranch) {
234+
log.info("Pulling latest base branch before creating worktree", {
235+
repoRoot,
236+
baseBranch: normalizedBaseBranch,
237+
});
238+
runGit(["pull", "origin", normalizedBaseBranch], repoRoot, env);
239+
} else {
240+
log.info("Fetching latest base branch before creating worktree", {
241+
repoRoot,
242+
baseBranch: normalizedBaseBranch,
243+
});
244+
runGit(["fetch", "origin", normalizedBaseBranch], repoRoot, env);
245+
}
199246

200247
log.info("Creating worktree for session", { worktreePath, worktreeId });
248+
const startPoint = hasLocalBaseBranch ? normalizedBaseBranch : `origin/${normalizedBaseBranch}`;
201249
if (localBranchExists(repoRoot, worktreeId, env)) {
202250
runGit(["worktree", "add", worktreePath, worktreeId], repoRoot, env);
203251
} else {
204-
runGit(["worktree", "add", worktreePath, "-b", worktreeId, "main"], repoRoot, env);
252+
runGit(["worktree", "add", worktreePath, "-b", worktreeId, startPoint], repoRoot, env);
205253
}
206254
copyEnvFile(repoRoot, worktreePath);
207255

0 commit comments

Comments
 (0)