Skip to content

Commit b6cf72f

Browse files
Copilotalexr00
andcommitted
Improve commit line break unwrapping for lists
- Fix list continuation to use content indent (not marker indent) - Support variable-length numbered list markers (1., 10., 100., etc.) - Allow flexible continuation spacing (1+ spaces, but not 4+ which is code) - Preserve multi-paragraph list formatting (blank lines within list items) - Track list context across blank lines for proper paragraph unwrapping - Add comprehensive test cases for all scenarios Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent 12a0086 commit b6cf72f

2 files changed

Lines changed: 209 additions & 36 deletions

File tree

src/github/folderRepositoryManager.ts

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3067,8 +3067,8 @@ function unwrapCommitMessageBody(body: string): string {
30673067
return body;
30683068
}
30693069

3070-
// Pattern to detect list item markers at the start of a line
3071-
const LIST_ITEM_PATTERN = /^[ \t]*([*+\-]|\d+\.)\s/;
3070+
// Pattern to detect list item markers at the start of a line and capture the marker
3071+
const LIST_ITEM_PATTERN = /^([ \t]*)([*+\-]|\d+\.)([ \t]+)/;
30723072
// Pattern to detect blockquote markers
30733073
const BLOCKQUOTE_PATTERN = /^[ \t]*>/;
30743074
// Pattern to detect fenced code block markers
@@ -3083,11 +3083,22 @@ function unwrapCommitMessageBody(body: string): string {
30833083
return base.length > 0 && !/\s$/.test(base) ? `${base} ${addition}` : `${base}${addition}`;
30843084
};
30853085

3086+
// Get the content indent for a list item (position where actual content starts)
3087+
const getListItemContentIndent = (line: string): number => {
3088+
const match = line.match(LIST_ITEM_PATTERN);
3089+
if (!match) {
3090+
return 0;
3091+
}
3092+
// Content indent = leading whitespace + marker + space after marker
3093+
return match[1].length + match[2].length + match[3].length;
3094+
};
3095+
30863096
const lines = body.split('\n');
30873097
const result: string[] = [];
30883098
let i = 0;
30893099
let inFencedBlock = false;
3090-
const listIndentStack: number[] = [];
3100+
// Stack stores { markerIndent, contentIndent } for each nesting level
3101+
const listStack: { markerIndent: number; contentIndent: number }[] = [];
30913102

30923103
const getNextNonBlankLineInfo = (
30933104
startIndex: number,
@@ -3106,19 +3117,23 @@ function unwrapCommitMessageBody(body: string): string {
31063117
return undefined;
31073118
};
31083119

3109-
const getActiveListIndent = (lineIndent: number): number | undefined => {
3110-
for (let idx = listIndentStack.length - 1; idx >= 0; idx--) {
3111-
const indentForLevel = listIndentStack[idx];
3112-
if (lineIndent >= indentForLevel + 2) {
3113-
listIndentStack.length = idx + 1;
3114-
return indentForLevel;
3120+
// Find the active list context for a given line indent
3121+
// Returns the content indent if the line is within an active list context
3122+
const getActiveListContentIndent = (lineIndent: number): number | undefined => {
3123+
for (let idx = listStack.length - 1; idx >= 0; idx--) {
3124+
const { markerIndent, contentIndent } = listStack[idx];
3125+
// A line is part of a list item if it has at least 1 space indent
3126+
// (but less than contentIndent + 4 which would be a code block)
3127+
if (lineIndent >= 1 && lineIndent >= markerIndent) {
3128+
listStack.length = idx + 1;
3129+
return contentIndent;
31153130
}
3116-
listIndentStack.pop();
3131+
listStack.pop();
31173132
}
31183133
return undefined;
31193134
};
31203135

3121-
const shouldJoinListContinuation = (lineIndex: number, activeIndent: number, baseLine: string): boolean => {
3136+
const shouldJoinListContinuation = (lineIndex: number, contentIndent: number, baseLine: string): boolean => {
31223137
const currentLine = lines[lineIndex];
31233138
if (!currentLine) {
31243139
return false;
@@ -3142,12 +3157,13 @@ function unwrapCommitMessageBody(body: string): string {
31423157
}
31433158

31443159
const currentIndent = getLeadingWhitespaceLength(currentLine);
3145-
if (currentIndent < activeIndent + 2) {
3160+
// Need at least 1 space to be a continuation
3161+
if (currentIndent < 1) {
31463162
return false;
31473163
}
31483164

3149-
// Treat indented code blocks (4+ spaces beyond the bullet) as preserve-only.
3150-
if (currentIndent >= activeIndent + 4) {
3165+
// 4+ spaces beyond content indent is an indented code block
3166+
if (currentIndent >= contentIndent + 4) {
31513167
return false;
31523168
}
31533169

@@ -3156,8 +3172,12 @@ function unwrapCommitMessageBody(body: string): string {
31563172
return true;
31573173
}
31583174

3159-
if (nextInfo.isListItem && nextInfo.indent <= activeIndent) {
3160-
return false;
3175+
// If next line is a list item at or before the current list level, don't join
3176+
if (nextInfo.isListItem) {
3177+
const currentListLevel = listStack.length > 0 ? listStack[listStack.length - 1].markerIndent : 0;
3178+
if (nextInfo.indent <= currentListLevel) {
3179+
return false;
3180+
}
31613181
}
31623182

31633183
return true;
@@ -3166,11 +3186,11 @@ function unwrapCommitMessageBody(body: string): string {
31663186
while (i < lines.length) {
31673187
const line = lines[i];
31683188

3169-
// Preserve blank lines
3189+
// Preserve blank lines but don't clear list context
3190+
// (multi-paragraph lists are allowed in GitHub markdown)
31703191
if (line.trim() === '') {
31713192
result.push(line);
31723193
i++;
3173-
listIndentStack.length = 0;
31743194
continue;
31753195
}
31763196

@@ -3190,26 +3210,25 @@ function unwrapCommitMessageBody(body: string): string {
31903210
}
31913211

31923212
const lineIndent = getLeadingWhitespaceLength(line);
3193-
const isListItem = LIST_ITEM_PATTERN.test(line);
3213+
const listItemMatch = line.match(LIST_ITEM_PATTERN);
31943214

3195-
if (isListItem) {
3196-
while (listIndentStack.length && lineIndent < listIndentStack[listIndentStack.length - 1]) {
3197-
listIndentStack.pop();
3198-
}
3215+
if (listItemMatch) {
3216+
const markerIndent = listItemMatch[1].length;
3217+
const contentIndent = getListItemContentIndent(line);
31993218

3200-
if (!listIndentStack.length || lineIndent > listIndentStack[listIndentStack.length - 1]) {
3201-
listIndentStack.push(lineIndent);
3202-
} else {
3203-
listIndentStack[listIndentStack.length - 1] = lineIndent;
3219+
// Pop list levels that are at or beyond this indent
3220+
while (listStack.length && markerIndent <= listStack[listStack.length - 1].markerIndent) {
3221+
listStack.pop();
32043222
}
32053223

3224+
listStack.push({ markerIndent, contentIndent });
32063225
result.push(line);
32073226
i++;
32083227
continue;
32093228
}
32103229

3211-
const activeListIndent = getActiveListIndent(lineIndent);
3212-
const codeIndentThreshold = activeListIndent !== undefined ? activeListIndent + 4 : 4;
3230+
const activeContentIndent = getActiveListContentIndent(lineIndent);
3231+
const codeIndentThreshold = activeContentIndent !== undefined ? activeContentIndent + 4 : 4;
32133232
const isBlockquote = BLOCKQUOTE_PATTERN.test(line);
32143233
const isIndentedCode = lineIndent >= codeIndentThreshold;
32153234

@@ -3219,34 +3238,84 @@ function unwrapCommitMessageBody(body: string): string {
32193238
continue;
32203239
}
32213240

3222-
if (activeListIndent !== undefined && lineIndent >= activeListIndent + 2) {
3241+
// Handle list item continuations
3242+
if (activeContentIndent !== undefined && lineIndent >= 1) {
32233243
const baseIndex = result.length - 1;
3224-
if (baseIndex >= 0) {
3225-
let baseLine = result[baseIndex];
3244+
// Only try to join with previous line if it's not blank
3245+
// Multi-paragraph lists have blank lines that should be preserved
3246+
const baseLine = baseIndex >= 0 ? result[baseIndex] : '';
3247+
const previousLineIsBlank = baseLine.trim() === '';
3248+
3249+
if (!previousLineIsBlank && baseIndex >= 0) {
3250+
let joinedLine = baseLine;
32263251
let appended = false;
32273252
let currentIndex = i;
32283253

32293254
while (
32303255
currentIndex < lines.length &&
3231-
shouldJoinListContinuation(currentIndex, activeListIndent, baseLine)
3256+
shouldJoinListContinuation(currentIndex, activeContentIndent, joinedLine)
32323257
) {
32333258
const continuationText = lines[currentIndex].trim();
32343259
if (continuationText) {
3235-
baseLine = appendWithSpace(baseLine, continuationText);
3260+
joinedLine = appendWithSpace(joinedLine, continuationText);
32363261
appended = true;
32373262
}
32383263
currentIndex++;
32393264
}
32403265

32413266
if (appended) {
3242-
result[baseIndex] = baseLine;
3267+
result[baseIndex] = joinedLine;
32433268
i = currentIndex;
32443269
continue;
32453270
}
32463271
}
32473272

3248-
result.push(line);
3273+
// For multi-paragraph continuations or standalone indented lines,
3274+
// preserve indentation but unwrap consecutive continuation lines
3275+
let joinedLine = line;
32493276
i++;
3277+
3278+
while (i < lines.length) {
3279+
const nextLine = lines[i];
3280+
3281+
if (nextLine.trim() === '') {
3282+
break;
3283+
}
3284+
3285+
if (FENCE_PATTERN.test(nextLine)) {
3286+
break;
3287+
}
3288+
3289+
if (LIST_ITEM_PATTERN.test(nextLine)) {
3290+
break;
3291+
}
3292+
3293+
if (BLOCKQUOTE_PATTERN.test(nextLine)) {
3294+
break;
3295+
}
3296+
3297+
const nextIndent = getLeadingWhitespaceLength(nextLine);
3298+
// Check for code block
3299+
if (nextIndent >= activeContentIndent + 4) {
3300+
break;
3301+
}
3302+
3303+
// Must have at least 1 space to be a continuation
3304+
if (nextIndent < 1) {
3305+
break;
3306+
}
3307+
3308+
// Check for hard line break
3309+
if (hasHardLineBreak(joinedLine)) {
3310+
break;
3311+
}
3312+
3313+
// Join this line - preserve the original indentation for the first line
3314+
joinedLine = appendWithSpace(joinedLine, nextLine.trim());
3315+
i++;
3316+
}
3317+
3318+
result.push(joinedLine);
32503319
continue;
32513320
}
32523321

src/test/github/folderRepositoryManager.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,108 @@ describe('titleAndBodyFrom', function () {
230230
assert.strictEqual(result?.title, 'title');
231231
assert.strictEqual(result?.body, '* This is a list item with two lines that have a line break between them\n * This is a nested list item that also has two lines that should have been merged');
232232
});
233+
234+
it('handles basic numeric list continuation', async function () {
235+
const message = Promise.resolve('title\n\n1. Basic numeric list\n continuation.\n Third line');
236+
237+
const result = await titleAndBodyFrom(message);
238+
assert.strictEqual(result?.title, 'title');
239+
assert.strictEqual(result?.body, '1. Basic numeric list continuation. Third line');
240+
});
241+
242+
it('handles additional spaces OK for continuation', async function () {
243+
const message = Promise.resolve('title\n\n2. Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line');
244+
245+
const result = await titleAndBodyFrom(message);
246+
assert.strictEqual(result?.title, 'title');
247+
assert.strictEqual(result?.body, '2. Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line');
248+
});
249+
250+
it('handles asterisk list with extra spaces', async function () {
251+
const message = Promise.resolve('title\n\n* Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line');
252+
253+
const result = await titleAndBodyFrom(message);
254+
assert.strictEqual(result?.title, 'title');
255+
assert.strictEqual(result?.body, '* Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line');
256+
});
257+
258+
it('handles multi-digit numbers (10.)', async function () {
259+
const message = Promise.resolve('title\n\n10. Multi-digit numbers should also\n work for a continuation.\n Third line');
260+
261+
const result = await titleAndBodyFrom(message);
262+
assert.strictEqual(result?.title, 'title');
263+
assert.strictEqual(result?.body, '10. Multi-digit numbers should also work for a continuation. Third line');
264+
});
265+
266+
it('handles multi-paragraph list - numbered', async function () {
267+
const message = Promise.resolve('title\n\n11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line');
268+
269+
const result = await titleAndBodyFrom(message);
270+
assert.strictEqual(result?.title, 'title');
271+
assert.strictEqual(result?.body, '11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line');
272+
});
273+
274+
it('handles multi-paragraph list - asterisk', async function () {
275+
const message = Promise.resolve('title\n\n* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line');
276+
277+
const result = await titleAndBodyFrom(message);
278+
assert.strictEqual(result?.title, 'title');
279+
assert.strictEqual(result?.body, '* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line');
280+
});
281+
282+
it('handles item with code block - numbered', async function () {
283+
const message = Promise.resolve('title\n\n1. Item with code:\n\n ```\n code line\n code line\n ```');
284+
285+
const result = await titleAndBodyFrom(message);
286+
assert.strictEqual(result?.title, 'title');
287+
assert.strictEqual(result?.body, '1. Item with code:\n\n ```\n code line\n code line\n ```');
288+
});
289+
290+
it('handles item with code block - asterisk', async function () {
291+
const message = Promise.resolve('title\n\n* Item with code:\n\n ```\n code line\n code line\n ```');
292+
293+
const result = await titleAndBodyFrom(message);
294+
assert.strictEqual(result?.title, 'title');
295+
assert.strictEqual(result?.body, '* Item with code:\n\n ```\n code line\n code line\n ```');
296+
});
297+
298+
it('handles fewer spaces OK - numbered (1 space)', async function () {
299+
const message = Promise.resolve('title\n\n1. Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)');
300+
301+
const result = await titleAndBodyFrom(message);
302+
assert.strictEqual(result?.title, 'title');
303+
assert.strictEqual(result?.body, '1. Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)');
304+
});
305+
306+
it('handles fewer spaces OK - asterisk (1 space)', async function () {
307+
const message = Promise.resolve('title\n\n* Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)');
308+
309+
const result = await titleAndBodyFrom(message);
310+
assert.strictEqual(result?.title, 'title');
311+
assert.strictEqual(result?.body, '* Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)');
312+
});
313+
314+
it('handles nested numbered lists', async function () {
315+
const message = Promise.resolve('title\n\n1. First level item\n continuation of first level\n 1. Nested numbered item\n with continuation');
316+
317+
const result = await titleAndBodyFrom(message);
318+
assert.strictEqual(result?.title, 'title');
319+
assert.strictEqual(result?.body, '1. First level item continuation of first level\n 1. Nested numbered item with continuation');
320+
});
321+
322+
it('handles nested multi-digit numbered lists', async function () {
323+
const message = Promise.resolve('title\n\n10. First level item with\n multi-line content\n 10. Nested with multi-digit\n number and continuation');
324+
325+
const result = await titleAndBodyFrom(message);
326+
assert.strictEqual(result?.title, 'title');
327+
assert.strictEqual(result?.body, '10. First level item with multi-line content\n 10. Nested with multi-digit number and continuation');
328+
});
329+
330+
it('handles nested multi-paragraph lists', async function () {
331+
const message = Promise.resolve('title\n\n* Outer item\n\n Second paragraph of outer\n with continuation\n * Inner item\n\n Second paragraph of inner\n with continuation');
332+
333+
const result = await titleAndBodyFrom(message);
334+
assert.strictEqual(result?.title, 'title');
335+
assert.strictEqual(result?.body, '* Outer item\n\n Second paragraph of outer with continuation\n * Inner item\n\n Second paragraph of inner with continuation');
336+
});
233337
});

0 commit comments

Comments
 (0)