Skip to content

Commit 6ddd9b8

Browse files
authored
Merge branch 'main' into t3code/edf90f03
2 parents 326b195 + 600491e commit 6ddd9b8

13 files changed

Lines changed: 271 additions & 25 deletions

File tree

src/config/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ function mergeConfigs(
247247
if (project.progressFile !== undefined)
248248
merged.progressFile = project.progressFile;
249249
if (project.autoCommit !== undefined) merged.autoCommit = project.autoCommit;
250+
if (project.commitMessageTemplate !== undefined)
251+
merged.commitMessageTemplate = project.commitMessageTemplate;
250252
if (project.subagentTracingDetail !== undefined) {
251253
merged.subagentTracingDetail = project.subagentTracingDetail;
252254
}
@@ -703,6 +705,7 @@ export async function buildConfig(
703705
// CLI --prompt takes precedence over config file prompt_template
704706
promptTemplate: options.promptPath ?? storedConfig.prompt_template,
705707
autoCommit: storedConfig.autoCommit ?? false,
708+
commitMessageTemplate: storedConfig.commitMessageTemplate,
706709
};
707710
}
708711

src/config/schema.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,22 @@ describe('StoredConfigSchema', () => {
522522
expect(result.envPassthrough).toEqual(['MY_API_KEY']);
523523
});
524524

525+
test('accepts commitMessageTemplate as an optional string', () => {
526+
const result = StoredConfigSchema.parse({
527+
commitMessageTemplate: '{{taskType}}({{taskId}}): {{taskTitle}}',
528+
});
529+
expect(result.commitMessageTemplate).toBe('{{taskType}}({{taskId}}): {{taskTitle}}');
530+
});
531+
532+
test('accepts empty config without commitMessageTemplate', () => {
533+
const result = StoredConfigSchema.parse({});
534+
expect(result.commitMessageTemplate).toBeUndefined();
535+
});
536+
537+
test('rejects commitMessageTemplate when set to a non-string', () => {
538+
expect(() => StoredConfigSchema.parse({ commitMessageTemplate: 42 })).toThrow();
539+
});
540+
525541
test('accepts top-level preflightTimeoutMs within bounds', () => {
526542
const result = StoredConfigSchema.parse({ preflightTimeoutMs: 60000 });
527543
expect(result.preflightTimeoutMs).toBe(60000);

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export const StoredConfigSchema = z
154154
iterationDelay: z.number().int().min(0).max(300000).optional(),
155155
outputDir: z.string().optional(),
156156
autoCommit: z.boolean().optional(),
157+
commitMessageTemplate: z.string().optional(),
157158

158159
// Plugin configurations
159160
agents: z.array(AgentPluginConfigSchema).optional(),

src/config/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,13 @@ export interface StoredConfig {
326326
/** Whether to auto-commit after successful tasks */
327327
autoCommit?: boolean;
328328

329+
/**
330+
* Handlebars template for auto-commit subject lines. Available variables:
331+
* `{{taskId}}`, `{{taskTitle}}`, `{{taskType}}` (falls back to `chore` when
332+
* the tracker omits a type). Defaults to `"{{taskType}}: {{taskId}} {{taskTitle}}"`.
333+
*/
334+
commitMessageTemplate?: string;
335+
329336
/** Custom prompt template path (relative to cwd or absolute) */
330337
prompt_template?: string;
331338

@@ -398,6 +405,13 @@ export interface RalphConfig {
398405
/** Whether to auto-commit after successful task completion (default: false) */
399406
autoCommit?: boolean;
400407

408+
/**
409+
* Handlebars template for auto-commit subject lines. When omitted, the default
410+
* `"{{taskType}}: {{taskId}} {{taskTitle}}"` is used (with `taskType` falling
411+
* back to `chore` when the tracker omits a type).
412+
*/
413+
commitMessageTemplate?: string;
414+
401415
/**
402416
* Optional list of task IDs to execute. When provided, only tasks with these
403417
* IDs will be executed, filtering out any others returned by the tracker.

src/engine/auto-commit.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
22
* ABOUTME: Auto-commit utility for committing changes after successful task completion.
3-
* Provides git operations to stage and commit changes when autoCommit is enabled.
3+
* Provides git operations to stage and commit changes when autoCommit is enabled,
4+
* plus Handlebars-based rendering of configurable commit subject templates.
45
*/
56

7+
import Handlebars from 'handlebars';
68
import { runProcess } from '../utils/process.js';
79

810
/**
@@ -21,6 +23,94 @@ export interface AutoCommitResult {
2123
error?: string;
2224
}
2325

26+
/**
27+
* Default commit subject template used when the user has not set
28+
* `commitMessageTemplate` in their config. Uses the tracker-reported task type
29+
* (with a `chore` fallback) so commits look Conventional-Commits-shaped without
30+
* mis-labeling everything as `feat:`.
31+
*/
32+
export const DEFAULT_COMMIT_MESSAGE_TEMPLATE = '{{taskType}}: {{taskId}} {{taskTitle}}';
33+
34+
/**
35+
* Fallback used when a task's `type` is missing or empty so user templates
36+
* always render to a non-empty subject.
37+
*/
38+
export const DEFAULT_TASK_TYPE = 'chore';
39+
40+
/**
41+
* Inputs for {@link renderCommitMessage}.
42+
*/
43+
export interface CommitMessageContext {
44+
taskId: string;
45+
taskTitle: string;
46+
taskType?: string;
47+
}
48+
49+
/**
50+
* Result of rendering a commit message template.
51+
*/
52+
export interface RenderCommitMessageResult {
53+
/** The rendered commit subject. Always non-empty. */
54+
message: string;
55+
/**
56+
* True when the caller-supplied template rendered to an empty/whitespace
57+
* string (or failed to compile) and we fell back to the default template.
58+
*/
59+
usedFallback: boolean;
60+
/** Compile/render error message when {@link usedFallback} is true. */
61+
fallbackReason?: string;
62+
}
63+
64+
function buildHandlebarsContext(ctx: CommitMessageContext): Record<string, string> {
65+
const type = ctx.taskType?.trim();
66+
return {
67+
taskId: ctx.taskId,
68+
taskTitle: ctx.taskTitle,
69+
taskType: type && type.length > 0 ? type : DEFAULT_TASK_TYPE,
70+
};
71+
}
72+
73+
function compileAndRender(template: string, ctx: Record<string, string>): string {
74+
const compiled = Handlebars.compile(template, { noEscape: true, strict: false });
75+
return compiled(ctx).trim();
76+
}
77+
78+
/**
79+
* Render a Handlebars commit subject template. Falls back to the default
80+
* template when the user-supplied template renders empty/whitespace-only or
81+
* throws while compiling.
82+
*/
83+
export function renderCommitMessage(
84+
template: string | undefined,
85+
ctx: CommitMessageContext
86+
): RenderCommitMessageResult {
87+
const handlebarsCtx = buildHandlebarsContext(ctx);
88+
const effectiveTemplate = template ?? DEFAULT_COMMIT_MESSAGE_TEMPLATE;
89+
90+
let rendered: string;
91+
try {
92+
rendered = compileAndRender(effectiveTemplate, handlebarsCtx);
93+
} catch (err) {
94+
const fallback = compileAndRender(DEFAULT_COMMIT_MESSAGE_TEMPLATE, handlebarsCtx);
95+
return {
96+
message: fallback,
97+
usedFallback: true,
98+
fallbackReason: err instanceof Error ? err.message : String(err),
99+
};
100+
}
101+
102+
if (rendered.length === 0) {
103+
const fallback = compileAndRender(DEFAULT_COMMIT_MESSAGE_TEMPLATE, handlebarsCtx);
104+
return {
105+
message: fallback,
106+
usedFallback: true,
107+
fallbackReason: 'template rendered to empty string',
108+
};
109+
}
110+
111+
return { message: rendered, usedFallback: false };
112+
}
113+
24114
/**
25115
* Check if there are uncommitted changes in the working directory.
26116
* Throws if git status cannot be determined (not a git repo, git not installed, etc.).
@@ -34,13 +124,12 @@ export async function hasUncommittedChanges(cwd: string): Promise<boolean> {
34124
}
35125

36126
/**
37-
* Stage all changes and create a commit with a standardized message format.
38-
* Returns the result of the operation including commit SHA on success.
127+
* Stage all changes and create a commit using the supplied subject line.
128+
* The caller is responsible for rendering any template — see {@link renderCommitMessage}.
39129
*/
40130
export async function performAutoCommit(
41131
cwd: string,
42-
taskId: string,
43-
taskTitle: string
132+
commitMessage: string
44133
): Promise<AutoCommitResult> {
45134
// Check for uncommitted changes first
46135
let hasChanges: boolean;
@@ -68,8 +157,7 @@ export async function performAutoCommit(
68157
};
69158
}
70159

71-
// Create commit with standardized message
72-
const commitMessage = `feat: ${taskId} - ${taskTitle}`;
160+
// Create commit with supplied message
73161
const commitResult = await runProcess(
74162
'git',
75163
['commit', '-m', commitMessage],

src/engine/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
} from '../plugins/agents/usage.js';
5757
import { updateSessionIteration, updateSessionStatus, updateSessionMaxIterations } from '../session/index.js';
5858
import { saveIterationLog, buildSubagentTrace, getRecentProgressSummary, getCodebasePatternsForPrompt } from '../logs/index.js';
59-
import { performAutoCommit } from './auto-commit.js';
59+
import { performAutoCommit, renderCommitMessage } from './auto-commit.js';
6060
import type { AgentSwitchEntry } from '../logs/index.js';
6161
import { renderPrompt } from '../templates/index.js';
6262
import { appendWithCharLimit as appendWithSharedCharLimit } from '../utils/buffer-limits.js';
@@ -2225,7 +2225,17 @@ export class ExecutionEngine {
22252225
*/
22262226
private async handleAutoCommit(task: TrackerTask, iteration: number): Promise<void> {
22272227
try {
2228-
const result = await performAutoCommit(this.config.cwd, task.id, task.title);
2228+
const rendered = renderCommitMessage(this.config.commitMessageTemplate, {
2229+
taskId: task.id,
2230+
taskTitle: task.title,
2231+
taskType: task.type,
2232+
});
2233+
if (rendered.usedFallback) {
2234+
console.warn(
2235+
`[auto-commit] commitMessageTemplate fell back to the default for task ${task.id}: ${rendered.fallbackReason}`
2236+
);
2237+
}
2238+
const result = await performAutoCommit(this.config.cwd, rendered.message);
22292239
if (result.committed) {
22302240
this.emit({
22312241
type: 'task:auto-committed',
@@ -2234,6 +2244,7 @@ export class ExecutionEngine {
22342244
iteration,
22352245
commitMessage: result.commitMessage!,
22362246
commitSha: result.commitSha,
2247+
templateFallbackReason: rendered.usedFallback ? rendered.fallbackReason : undefined,
22372248
});
22382249
} else if (result.error) {
22392250
this.emit({

src/engine/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,13 @@ export interface TaskAutoCommittedEvent extends EngineEventBase {
479479
commitMessage: string;
480480
/** Short SHA of the commit (if available) */
481481
commitSha?: string;
482+
/**
483+
* Populated when a configured `commitMessageTemplate` failed to render
484+
* (Handlebars error or empty/whitespace result) and the engine fell back
485+
* to the default template. Always undefined when no template was set or
486+
* the template rendered cleanly.
487+
*/
488+
templateFallbackReason?: string;
482489
}
483490

484491
/**

0 commit comments

Comments
 (0)