Skip to content

Commit ce0ef2f

Browse files
authored
fix: git log issue (#2595)
* update list commits to be more resistant to changes * make sure git log commands don't activate vim
1 parent b375997 commit ce0ef2f

File tree

6 files changed

+309
-82
lines changed

6 files changed

+309
-82
lines changed

apps/web/client/src/components/store/editor/version/git.ts

Lines changed: 108 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { prepareCommitMessage, sanitizeCommitMessage } from '@/utils/git';
12
import { type GitCommit } from '@onlook/git';
3+
import stripAnsi from 'strip-ansi';
24
import type { EditorEngine } from '../engine';
35

46
export const ONLOOK_DISPLAY_NAME_NOTE_REF = 'refs/notes/onlook-display-name';
@@ -145,28 +147,54 @@ export class GitManager {
145147
* Create a commit
146148
*/
147149
async commit(message: string): Promise<GitCommandResult> {
148-
const escapedMessage = message.replace(/\"/g, '\\"');
149-
return this.runCommand(`git commit --allow-empty --no-verify -m "${escapedMessage}"`);
150+
const sanitizedMessage = sanitizeCommitMessage(message);
151+
const escapedMessage = prepareCommitMessage(sanitizedMessage);
152+
return this.runCommand(`git commit --allow-empty --no-verify -m ${escapedMessage}`);
150153
}
151154

152155
/**
153156
* List commits with formatted output
154157
*/
155-
async listCommits(): Promise<GitCommit[]> {
156-
try {
157-
const result = await this.runCommand(
158-
'git log --pretty=format:"%H|%an <%ae>|%ad|%s" --date=iso',
159-
);
158+
async listCommits(maxRetries = 2): Promise<GitCommit[]> {
159+
let lastError: Error | null = null;
160+
161+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
162+
try {
163+
// Use a more robust format with unique separators and handle multiline messages
164+
const result = await this.runCommand(
165+
'git --no-pager log --pretty=format:"COMMIT_START%n%H%n%an <%ae>%n%ad%n%B%nCOMMIT_END" --date=iso',
166+
);
160167

161-
if (result.success && result.output) {
162-
return this.parseGitLog(result.output);
163-
}
168+
if (result.success && result.output) {
169+
return this.parseGitLog(result.output);
170+
}
164171

165-
return [];
166-
} catch (error) {
167-
console.error('Failed to list commits', error);
168-
return [];
172+
// If git command failed but didn't throw, treat as error for retry logic
173+
lastError = new Error(`Git command failed: ${result.error || 'Unknown error'}`);
174+
175+
if (attempt < maxRetries) {
176+
// Wait before retry with exponential backoff
177+
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
178+
continue;
179+
}
180+
181+
return [];
182+
} catch (error) {
183+
lastError = error instanceof Error ? error : new Error(String(error));
184+
console.warn(`Attempt ${attempt + 1} failed to list commits:`, lastError.message);
185+
186+
if (attempt < maxRetries) {
187+
// Wait before retry with exponential backoff
188+
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
189+
continue;
190+
}
191+
192+
console.error('All attempts failed to list commits', lastError);
193+
return [];
194+
}
169195
}
196+
197+
return [];
170198
}
171199

172200
/**
@@ -180,9 +208,10 @@ export class GitManager {
180208
* Add a display name note to a commit
181209
*/
182210
async addCommitNote(commitOid: string, displayName: string): Promise<GitCommandResult> {
183-
const escapedDisplayName = displayName.replace(/\"/g, '\\"');
211+
const sanitizedDisplayName = sanitizeCommitMessage(displayName);
212+
const escapedDisplayName = prepareCommitMessage(sanitizedDisplayName);
184213
return this.runCommand(
185-
`git notes --ref=${ONLOOK_DISPLAY_NAME_NOTE_REF} add -f -m "${escapedDisplayName}" ${commitOid}`,
214+
`git notes --ref=${ONLOOK_DISPLAY_NAME_NOTE_REF} add -f -m ${escapedDisplayName} ${commitOid}`,
186215
);
187216
}
188217

@@ -195,7 +224,11 @@ export class GitManager {
195224
`git notes --ref=${ONLOOK_DISPLAY_NAME_NOTE_REF} show ${commitOid}`,
196225
true,
197226
);
198-
return result.success ? this.formatGitLogOutput(result.output) : null;
227+
if (result.success && result.output) {
228+
const cleanOutput = this.formatGitLogOutput(result.output);
229+
return cleanOutput || null;
230+
}
231+
return null;
199232
} catch (error) {
200233
console.warn('Failed to get commit note', error);
201234
return null;
@@ -220,71 +253,77 @@ export class GitManager {
220253
}
221254

222255
const commits: GitCommit[] = [];
223-
const lines = cleanOutput.split('\n').filter((line) => line.trim());
224256

225-
for (const line of lines) {
226-
if (!line.trim()) continue;
257+
// Split by COMMIT_START and COMMIT_END markers
258+
const commitBlocks = cleanOutput.split('COMMIT_START').filter(block => block.trim());
227259

228-
// Handle the new format: <hash>|<author>|<date>|<message>
229-
// The hash might have a prefix that we need to handle
230-
let cleanLine = line;
260+
for (const block of commitBlocks) {
261+
// Remove COMMIT_END if present
262+
const cleanBlock = block.replace(/COMMIT_END\s*$/, '').trim();
263+
if (!cleanBlock) continue;
231264

232-
// If line starts with escape sequences followed by =, extract everything after =
233-
const escapeMatch = cleanLine.match(/^[^\w]*=?(.+)$/);
234-
if (escapeMatch) {
235-
cleanLine = escapeMatch[1] || '';
236-
}
265+
// Split the block into lines
266+
const lines = cleanBlock.split('\n');
267+
268+
if (lines.length < 4) continue; // Need at least hash, author, date, and message
269+
270+
const hash = lines[0]?.trim();
271+
const authorLine = lines[1]?.trim();
272+
const dateLine = lines[2]?.trim();
237273

238-
const parts = cleanLine.split('|');
239-
if (parts.length >= 4) {
240-
const hash = parts[0]?.trim();
241-
const authorLine = parts[1]?.trim();
242-
const dateLine = parts[2]?.trim();
243-
const message = parts.slice(3).join('|').trim();
244-
245-
if (!hash || !authorLine || !dateLine) continue;
246-
247-
// Parse author name and email
248-
const authorMatch = authorLine.match(/^(.+?)\s*<(.+?)>$/);
249-
const authorName = authorMatch?.[1]?.trim() || authorLine;
250-
const authorEmail = authorMatch?.[2]?.trim() || '';
251-
252-
// Parse date to timestamp
253-
const timestamp = Math.floor(new Date(dateLine).getTime() / 1000);
254-
255-
commits.push({
256-
oid: hash,
257-
message: message || 'No message',
258-
author: {
259-
name: authorName,
260-
email: authorEmail,
261-
},
262-
timestamp: timestamp,
263-
displayName: message || null,
264-
});
274+
// Everything from line 3 onwards is the commit message (including empty lines)
275+
const messageLines = lines.slice(3);
276+
// Join all message lines and trim only leading/trailing whitespace
277+
const message = messageLines.join('\n').trim();
278+
279+
if (!hash || !authorLine || !dateLine) continue;
280+
281+
// Parse author name and email
282+
const authorMatch = authorLine.match(/^(.+?)\s*<(.+?)>$/);
283+
const authorName = authorMatch?.[1]?.trim() || authorLine;
284+
const authorEmail = authorMatch?.[2]?.trim() || '';
285+
286+
// Parse date to timestamp
287+
let timestamp: number;
288+
try {
289+
timestamp = Math.floor(new Date(dateLine).getTime() / 1000);
290+
// Validate timestamp
291+
if (isNaN(timestamp) || timestamp < 0) {
292+
timestamp = Math.floor(Date.now() / 1000);
293+
}
294+
} catch (error) {
295+
console.warn('Failed to parse commit date:', dateLine, error);
296+
timestamp = Math.floor(Date.now() / 1000);
265297
}
298+
299+
// Use the first line of the message as display name, or the full message if it's short
300+
const displayMessage = message.split('\n')[0] || 'No message';
301+
302+
commits.push({
303+
oid: hash,
304+
message: message || 'No message',
305+
author: {
306+
name: authorName,
307+
email: authorEmail,
308+
},
309+
timestamp: timestamp,
310+
displayName: displayMessage,
311+
});
266312
}
267313

268314
return commits;
269315
}
270316

271317
private formatGitLogOutput(input: string): string {
272-
// Handle sequences with ESC characters anywhere within them
273-
// Pattern to match sequences like [?1h<ESC>= and [K<ESC>[?1l<ESC>>
274-
const ansiWithEscPattern = /\[[0-9;?a-zA-Z\x1b]*[a-zA-Z=>/]*/g;
275-
276-
// Handle standard ANSI escape sequences starting with ESC
277-
const ansiEscapePattern = /\x1b\[[0-9;?a-zA-Z]*[a-zA-Z=>/]*/g;
318+
// Use strip-ansi library for robust ANSI escape sequence removal
319+
let cleanOutput = stripAnsi(input);
278320

279-
// Handle control characters
280-
const controlChars = /[\x00-\x09\x0B-\x1F\x7F]/g;
321+
// Remove any remaining control characters except newline and tab
322+
cleanOutput = cleanOutput.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
281323

282-
const cleanOutput = input
283-
.replace(ansiWithEscPattern, '') // Remove sequences with ESC chars in middle
284-
.replace(ansiEscapePattern, '') // Remove standard ESC sequences
285-
.replace(controlChars, '') // Remove control characters
286-
.trim();
324+
// Remove null bytes
325+
cleanOutput = cleanOutput.replace(/\0/g, '');
287326

288-
return cleanOutput;
327+
return cleanOutput.trim();
289328
}
290329
}

apps/web/client/src/components/store/editor/version/index.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sanitizeCommitMessage } from '@/utils/git';
12
import { type GitCommit } from '@onlook/git';
23
import { toast } from '@onlook/ui/sonner';
34
import { makeAutoObservable } from 'mobx';
@@ -16,6 +17,7 @@ export class VersionsManager {
1617
isSaving = false;
1718
isLoadingCommits = false;
1819
private gitManager: GitManager;
20+
private listCommitsPromise: Promise<GitCommit[]> | null = null;
1921

2022
constructor(private editorEngine: EditorEngine) {
2123
makeAutoObservable(this);
@@ -95,8 +97,9 @@ export class VersionsManager {
9597
//Check config is set
9698
await this.gitManager.ensureGitConfig();
9799

98-
// Create the commit
99-
const commitResult = await this.gitManager.commit(message);
100+
// Create the commit with sanitized message
101+
const sanitizedMessage = sanitizeCommitMessage(message);
102+
const commitResult = await this.gitManager.commit(sanitizedMessage);
100103
if (!commitResult.success) {
101104
if (showToast) {
102105
toast.error('Failed to create backup');
@@ -108,18 +111,18 @@ export class VersionsManager {
108111
};
109112
}
110113

111-
// Refresh the commits list
114+
// Refresh the commits list after creating commit
112115
const commits = await this.listCommits();
113116

114117
if (showToast) {
115118
toast.success('Backup created successfully!', {
116-
description: `Created backup: "${message}"`,
119+
description: `Created backup: "${sanitizedMessage}"`,
117120
});
118121
}
119122
this.isSaving = true;
120123

121124
this.editorEngine.posthog.capture('versions_create_commit_success', {
122-
message,
125+
message: sanitizedMessage,
123126
});
124127

125128
const latestCommit = commits.length > 0 ? commits[0] ?? null : null;
@@ -145,9 +148,27 @@ export class VersionsManager {
145148
}
146149
};
147150

148-
listCommits = async () => {
149-
this.isLoadingCommits = true;
151+
listCommits = async (): Promise<GitCommit[]> => {
152+
// Return existing promise if already in progress
153+
if (this.listCommitsPromise) {
154+
return this.listCommitsPromise;
155+
}
156+
157+
// Create and store the promise
158+
this.listCommitsPromise = this.performListCommits();
159+
160+
try {
161+
const result = await this.listCommitsPromise;
162+
return result;
163+
} finally {
164+
// Clear the promise when complete
165+
this.listCommitsPromise = null;
166+
}
167+
};
168+
169+
private performListCommits = async (): Promise<GitCommit[]> => {
150170
try {
171+
this.isLoadingCommits = true;
151172
this.commits = await this.gitManager.listCommits();
152173

153174
// Enhance commits with display names from notes
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { observer } from 'mobx-react-lite';
2-
import { Versions } from './versions';
3-
import { useEffect } from 'react';
41
import { useEditorEngine } from '@/components/store/editor';
2+
import { useEffect } from 'react';
3+
import { Versions } from './versions';
54

6-
export const VersionsTab = observer(() => {
5+
export const VersionsTab = () => {
76
const editorEngine = useEditorEngine();
7+
88
useEffect(() => {
99
editorEngine.versions.listCommits();
1010
}, []);
11+
1112
return (
1213
<div className="flex flex-col h-full relative text-sm">
1314
<Versions />
1415
</div>
1516
);
16-
});
17+
};

apps/web/client/src/utils/constants/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ export const ExternalRoutes = {
2929
YOUTUBE: 'https://www.youtube.com/@onlookdev',
3030
SUBSTACK: 'https://onlook.substack.com/',
3131
DISCORD: 'https://discord.gg/ZZzadNQtns',
32-
};
32+
};
33+
34+
export const Git = {
35+
MAX_COMMIT_MESSAGE_LENGTH: 72,
36+
MAX_COMMIT_MESSAGE_BODY_LENGTH: 500,
37+
} as const;

0 commit comments

Comments
 (0)