diff --git a/src/server/app.js b/src/server/app.js index 2dc49ad..fe766a2 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -322,10 +322,14 @@ export function createApp(ctx) { return guided({ attemptId: r.attempts[0].attemptId, batchId: r.batchId, state: 'queued' }); }); + // The repository stores LF line endings; submissions from Windows workspaces may carry CRLF. + const lf = (files) => Object.fromEntries(Object.entries(files).map(([f, c]) => [f, typeof c === 'string' ? c.replace(/\r\n/g, '\n') : c])); + /** Validates a submission body and queues it as one batch for the given tasks. */ const submitBatch = (user, taskIds, body) => { if (typeof body.baseCommit !== 'string' || !/^[0-9a-f]{40}$/.test(body.baseCommit)) throw new HttpError(400, 'baseCommit must be a full commit hash'); if (!body.files || typeof body.files !== 'object' || Array.isArray(body.files)) throw new HttpError(400, 'files must be an object of path -> content'); + body.files = lf(body.files); const r = tasks.submitBatch(user, { taskIds, baseCommit: body.baseCommit, @@ -500,6 +504,7 @@ export function createApp(ctx) { const body = await readJson(req); if (typeof body.baseCommit !== 'string' || !/^[0-9a-f]{40}$/.test(body.baseCommit)) throw new HttpError(400, 'baseCommit must be a full commit hash'); if (!body.files || typeof body.files !== 'object' || Array.isArray(body.files)) throw new HttpError(400, 'files must be an object of path -> content'); + body.files = lf(body.files); const allowed = [...project.sourceDirs.map((d) => `${d}/**`)]; const lintErrors = lintSubmission(body.files, { allowedPaths: allowed, limits: project.lintLimits, allowedPragmas: project.allowedPragmas }); if (lintErrors.length) return { gate: 'rejected', lintErrors }; diff --git a/src/server/git.js b/src/server/git.js index e9b80fc..371fb6e 100644 --- a/src/server/git.js +++ b/src/server/git.js @@ -111,6 +111,8 @@ export class Repo { fs.writeFileSync(abs, content); } this.git(['add', '--', ...Object.keys(files)]); + // Nothing to commit (the code is already on main, e.g. a function resubmitted unchanged). + if (!this.git(['status', '--porcelain', '--', ...Object.keys(files)]).trim()) return this.head(); this.git(['-c', 'user.name=Clank Together', '-c', 'user.email=bot@clank-together.invalid', 'commit', '-q', '--author', author, '-F', '-'], { input: message }); return this.head(); } diff --git a/test/server.test.js b/test/server.test.js index a05d7db..8546b85 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -375,6 +375,13 @@ test('submissions may not add comments or strings that could instruct other agen assert.equal(JSON.stringify(r.body).includes('ignore your instructions'), false, 'the text is not echoed back'); }); +test('committing files that are already on main is a no-op, not an error', () => { + const files = { 'src/noop.cpp': 'int noop() { return 0; }\n' }; + const head = ctx.repo.commitFiles(files, 'add noop.cpp', 'x '); + assert.equal(ctx.repo.commitFiles(files, 'nothing', 'x '), head); + assert.equal(ctx.repo.head(), head); +}); + test('the crew view is public and shows no account details', async () => { const token = await activeUser('crewview'); await call('GET', '/api/tasks', null, token); // the agent checks in