Skip to content

Commit efb4cf4

Browse files
olupclaude
andcommitted
feat: set PR base branch from first remote bookmark in commit history
When creating a PR, walk the ancestor commits of the bookmark to find the first bookmark that exists on the remote, and use it as the base branch in the GitHub compare URL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5bdb4da commit efb4cf4

4 files changed

Lines changed: 80 additions & 211 deletions

File tree

package.json

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "open-jj",
33
"displayName": "OPEN JJ",
44
"description": "Jujutsu (jj) version control integration for VS Code",
5-
"version": "0.0.9",
5+
"version": "0.0.10",
66
"publisher": "olup",
77
"license": "MIT",
88
"engines": {
@@ -69,12 +69,6 @@
6969
"title": "Abandon Change",
7070
"category": "JJ"
7171
},
72-
{
73-
"command": "open-jj.bookmark.manage",
74-
"title": "Manage Bookmarks",
75-
"category": "JJ",
76-
"icon": "$(bookmark)"
77-
},
7872
{
7973
"command": "open-jj.bookmark.create",
8074
"title": "Create Bookmark",

src/extension.ts

Lines changed: 0 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -450,202 +450,6 @@ function registerCoreCommands(context: vscode.ExtensionContext): void {
450450
})
451451
);
452452

453-
// Manage bookmarks - tag-style UI
454-
context.subscriptions.push(
455-
vscode.commands.registerCommand('open-jj.bookmark.manage', async (arg?: Change | { change: Change }) => {
456-
if (!repository) {
457-
return;
458-
}
459-
460-
const change = arg && 'change' in arg ? arg.change : arg;
461-
const targetRevision = change?.changeIdShort ?? '@';
462-
const targetChangeId = change?.changeId ?? repository.currentChange?.changeId;
463-
464-
// Get bookmarks on this change
465-
const bookmarksOnChange = repository.bookmarks.filter(
466-
b => !b.isRemote && targetChangeId && b.changeId === targetChangeId
467-
);
468-
const otherBookmarks = repository.bookmarks.filter(
469-
b => !b.isRemote && (!targetChangeId || b.changeId !== targetChangeId)
470-
);
471-
472-
interface ActionItem extends vscode.QuickPickItem {
473-
action: 'remove' | 'add' | 'create';
474-
bookmarkName?: string;
475-
}
476-
477-
const showPicker = async () => {
478-
// Refresh bookmark lists
479-
// Compare using startsWith since bookmark changeId might be short
480-
const isOnChange = (b: { changeId: string }) => {
481-
if (!targetChangeId) return false;
482-
return targetChangeId.startsWith(b.changeId) || b.changeId.startsWith(targetChangeId);
483-
};
484-
485-
const currentOnChange = repository!.bookmarks.filter(
486-
b => !b.isRemote && isOnChange(b)
487-
);
488-
const currentOther = repository!.bookmarks.filter(
489-
b => !b.isRemote && !isOnChange(b)
490-
);
491-
492-
const buildItems = (filter: string): ActionItem[] => {
493-
const items: ActionItem[] = [];
494-
const filterLower = filter.toLowerCase();
495-
496-
// Current bookmarks on this change (removable)
497-
const matchingOnChange = currentOnChange.filter(b => b.name.toLowerCase().includes(filterLower));
498-
if (matchingOnChange.length > 0) {
499-
items.push({
500-
label: 'On this change',
501-
kind: vscode.QuickPickItemKind.Separator,
502-
action: 'remove',
503-
});
504-
for (const b of matchingOnChange) {
505-
items.push({
506-
label: `$(close) ${b.name}`,
507-
description: 'click to remove',
508-
action: 'remove',
509-
bookmarkName: b.name,
510-
});
511-
}
512-
}
513-
514-
// Other bookmarks (can be moved here)
515-
const matchingOther = currentOther.filter(b => b.name.toLowerCase().includes(filterLower));
516-
if (matchingOther.length > 0) {
517-
items.push({
518-
label: 'Move to this change',
519-
kind: vscode.QuickPickItemKind.Separator,
520-
action: 'add',
521-
});
522-
for (const b of matchingOther) {
523-
items.push({
524-
label: `$(add) ${b.name}`,
525-
description: b.changeId.slice(0, 8),
526-
action: 'add',
527-
bookmarkName: b.name,
528-
});
529-
}
530-
}
531-
532-
// Add "Create xxx" option if there's text and no exact match
533-
const allBookmarks = [...currentOnChange, ...currentOther];
534-
const hasExactMatch = allBookmarks.some(b => b.name.toLowerCase() === filterLower);
535-
if (filter.trim() && !hasExactMatch) {
536-
items.push({
537-
label: `$(plus) Create "${filter.trim()}"`,
538-
description: 'Create new bookmark',
539-
action: 'create',
540-
bookmarkName: filter.trim(),
541-
});
542-
} else {
543-
// Create new option (static)
544-
items.push({
545-
label: 'Create new bookmark...',
546-
kind: vscode.QuickPickItemKind.Separator,
547-
action: 'create',
548-
});
549-
items.push({
550-
label: '$(plus) Create new bookmark',
551-
description: 'type a name for the new bookmark',
552-
action: 'create',
553-
});
554-
}
555-
556-
return items;
557-
};
558-
559-
const quickPick = vscode.window.createQuickPick<ActionItem>();
560-
quickPick.title = `Bookmarks on ${change?.changeIdShort ?? '@'}`;
561-
quickPick.placeholder = currentOnChange.length > 0
562-
? `Current: ${currentOnChange.map(b => b.name).join(', ')}`
563-
: 'No bookmarks on this change';
564-
quickPick.items = buildItems('');
565-
quickPick.matchOnDescription = true;
566-
567-
quickPick.onDidChangeValue((value) => {
568-
quickPick.items = buildItems(value);
569-
});
570-
571-
const picked = await new Promise<ActionItem | undefined>((resolve) => {
572-
quickPick.onDidAccept(() => {
573-
resolve(quickPick.selectedItems[0]);
574-
quickPick.hide();
575-
});
576-
quickPick.onDidHide(() => {
577-
resolve(undefined);
578-
quickPick.dispose();
579-
});
580-
quickPick.show();
581-
});
582-
583-
if (!picked || picked.kind === vscode.QuickPickItemKind.Separator) {
584-
return;
585-
}
586-
587-
if (picked.action === 'remove' && picked.bookmarkName) {
588-
// Remove = move to a new empty change (abandon it from here)
589-
// Actually in jj we can't easily "remove" a bookmark, we just move it
590-
// Let's ask where to move it or delete it
591-
const choice = await vscode.window.showQuickPick([
592-
{ label: '$(trash) Delete bookmark', value: 'delete' },
593-
{ label: '$(arrow-right) Move to different change...', value: 'move' },
594-
], {
595-
title: `What to do with "${picked.bookmarkName}"?`,
596-
});
597-
598-
if (choice?.value === 'delete') {
599-
await repository!.deleteBookmark(picked.bookmarkName);
600-
vscode.window.showInformationMessage(`Deleted bookmark "${picked.bookmarkName}"`);
601-
} else if (choice?.value === 'move') {
602-
const targetChange = await vscode.window.showInputBox({
603-
prompt: 'Enter change ID to move bookmark to',
604-
placeHolder: 'e.g., abc123',
605-
});
606-
if (targetChange) {
607-
await repository!.setBookmark(picked.bookmarkName, targetChange);
608-
vscode.window.showInformationMessage(`Moved "${picked.bookmarkName}" to ${targetChange}`);
609-
}
610-
}
611-
await showPicker(); // Show picker again
612-
} else if (picked.action === 'add' && picked.bookmarkName) {
613-
await repository!.setBookmark(picked.bookmarkName, targetRevision);
614-
vscode.window.showInformationMessage(`Moved "${picked.bookmarkName}" here`);
615-
await showPicker(); // Show picker again
616-
} else if (picked.action === 'create') {
617-
let name = picked.bookmarkName;
618-
if (!name) {
619-
name = await vscode.window.showInputBox({
620-
prompt: 'Enter new bookmark name',
621-
placeHolder: 'bookmark-name',
622-
validateInput: (v) => {
623-
if (!v?.trim()) return 'Name required';
624-
if (!/^[\w\-./]+$/.test(v)) return 'Invalid characters';
625-
if (repository!.bookmarks.some(b => b.name === v)) return 'Already exists';
626-
return null;
627-
},
628-
});
629-
}
630-
if (name) {
631-
// Validate the name
632-
if (!/^[\w\-./]+$/.test(name)) {
633-
vscode.window.showErrorMessage('Invalid bookmark name');
634-
} else if (repository!.bookmarks.some(b => b.name === name)) {
635-
vscode.window.showErrorMessage('Bookmark already exists');
636-
} else {
637-
await repository!.createBookmark(name, targetRevision);
638-
vscode.window.showInformationMessage(`Created bookmark "${name}"`);
639-
}
640-
}
641-
await showPicker(); // Show picker again
642-
}
643-
};
644-
645-
await showPicker();
646-
})
647-
);
648-
649453
// Create bookmark (simple version, kept for compatibility)
650454
context.subscriptions.push(
651455
vscode.commands.registerCommand('open-jj.bookmark.create', async (arg?: Change | { change: Change }) => {

src/repository/repository.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,72 @@ export class Repository implements vscode.Disposable {
549549
return session !== null;
550550
}
551551

552+
/**
553+
* Find the base branch for a PR by walking ancestors to find the first
554+
* bookmark that exists on the remote.
555+
*/
556+
findBaseBranchForPr(bookmarkName: string): string | null {
557+
// Build set of bookmark names that exist on remote
558+
const remoteBookmarkNames = new Set<string>();
559+
for (const b of this._bookmarks) {
560+
if (b.isRemote && b.remote !== 'git') {
561+
remoteBookmarkNames.add(b.name);
562+
}
563+
}
564+
565+
const normalizeBookmarkName = (name: string): string => {
566+
const withoutConflict = name.endsWith('*') ? name.slice(0, -1) : name;
567+
const atIndex = withoutConflict.indexOf('@');
568+
return atIndex === -1 ? withoutConflict : withoutConflict.slice(0, atIndex);
569+
};
570+
571+
// Build commitId -> Change map from full log
572+
const changeByCommitId = new Map<string, Change>();
573+
for (const change of this._fullLog) {
574+
changeByCommitId.set(change.commitId, change);
575+
}
576+
577+
// Find the change that has this bookmark
578+
const startChange = this._fullLog.find(c =>
579+
c.bookmarks.some(b => normalizeBookmarkName(b) === bookmarkName)
580+
);
581+
if (!startChange) {
582+
return null;
583+
}
584+
585+
// BFS through ancestors (skip the start change itself)
586+
const queue = [...startChange.parentIds];
587+
const visited = new Set<string>();
588+
589+
while (queue.length > 0) {
590+
const commitId = queue.shift()!;
591+
if (visited.has(commitId)) {
592+
continue;
593+
}
594+
visited.add(commitId);
595+
596+
const change = changeByCommitId.get(commitId);
597+
if (!change) {
598+
continue;
599+
}
600+
601+
// Check if any bookmark on this ancestor exists on remote
602+
for (const bName of change.bookmarks) {
603+
const normalized = normalizeBookmarkName(bName);
604+
if (normalized && remoteBookmarkNames.has(normalized)) {
605+
return normalized;
606+
}
607+
}
608+
609+
// Continue to parents
610+
for (const parentId of change.parentIds) {
611+
queue.push(parentId);
612+
}
613+
}
614+
615+
return null;
616+
}
617+
552618
/**
553619
* Get the absolute path for a relative file path
554620
*/

src/views/logWebview.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,6 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
376376
vscode.commands.executeCommand('open-jj.edit', { change: this._findChange(message.changeId as string) });
377377
break;
378378

379-
case 'manageBookmarks':
380-
vscode.commands.executeCommand('open-jj.bookmark.manage', { change: this._findChange(message.changeId as string) });
381-
break;
382-
383379
case 'describeChange':
384380
vscode.commands.executeCommand('open-jj.describe', this._findChange(message.changeId as string));
385381
break;
@@ -436,6 +432,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
436432
vscode.commands.executeCommand('open-jj.new', { revision: message.changeId });
437433
break;
438434

435+
case 'createBookmark':
436+
vscode.commands.executeCommand('open-jj.bookmark.create', { change: this._findChange(message.changeId as string) });
437+
break;
438+
439439
case 'refresh':
440440
await repo.refresh({ refreshPrInfo: true });
441441
break;
@@ -502,7 +502,8 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
502502
const prBookmark = message.bookmarkName as string;
503503
const remoteUrl = await repo.getRemoteUrl();
504504
if (remoteUrl) {
505-
const githubUrl = this._convertToGitHubPrUrl(remoteUrl, prBookmark);
505+
const baseBranch = repo.findBaseBranchForPr(prBookmark) ?? undefined;
506+
const githubUrl = this._convertToGitHubPrUrl(remoteUrl, prBookmark, baseBranch);
506507
if (githubUrl) {
507508
vscode.env.openExternal(vscode.Uri.parse(githubUrl));
508509
} else {
@@ -531,7 +532,8 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
531532
// Now open PR creation page
532533
const pushPrRemoteUrl = await repo.getRemoteUrl();
533534
if (pushPrRemoteUrl) {
534-
const pushPrGitHubUrl = this._convertToGitHubPrUrl(pushPrRemoteUrl, pushPrBookmark);
535+
const pushPrBaseBranch = repo.findBaseBranchForPr(pushPrBookmark) ?? undefined;
536+
const pushPrGitHubUrl = this._convertToGitHubPrUrl(pushPrRemoteUrl, pushPrBookmark, pushPrBaseBranch);
535537
if (pushPrGitHubUrl) {
536538
vscode.env.openExternal(vscode.Uri.parse(pushPrGitHubUrl));
537539
} else {
@@ -573,13 +575,16 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
573575
return this._repository?.log.filter(c => c.changeId === changeId || c.changeIdShort === changeId) ?? [];
574576
}
575577

576-
private _convertToGitHubPrUrl(remoteUrl: string, branchName: string): string | null {
578+
private _convertToGitHubPrUrl(remoteUrl: string, branchName: string, baseBranch?: string): string | null {
577579
// Convert git remote URL to GitHub PR creation URL
578580
// Handles: git@github.com:owner/repo.git, https://github.com/owner/repo.git
579581
let match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/);
580582
if (match) {
581583
const owner = match[1];
582584
const repo = match[2];
585+
if (baseBranch) {
586+
return `https://github.com/${owner}/${repo}/compare/${baseBranch}...${branchName}?expand=1`;
587+
}
583588
return `https://github.com/${owner}/${repo}/compare/${branchName}?expand=1`;
584589
}
585590
return null;
@@ -853,7 +858,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider {
853858
}
854859

855860
if (badges.length === 0) return '';
856-
return `<span class="bookmarks" data-action="manage-bookmarks" data-change-id="${changeId}" title="Manage Bookmarks">${badges.join('')}</span>`;
861+
return `<span class="bookmarks">${badges.join('')}</span>`;
857862
}
858863

859864
private _renderFiles(files: FileChange[], revision?: string, changeId?: string, graphInfo?: GraphInfo): string {

0 commit comments

Comments
 (0)