Using merge strategy

This commit is contained in:
Osvaldo Ortega
2026-03-03 15:43:40 -08:00
parent dd8539f530
commit 26b6024286
9 changed files with 254 additions and 226 deletions

View File

@@ -1075,6 +1075,22 @@ export class CommandCenter {
}
}
@command('_git.revParseAbbrevRef')
async revParseAbbrevRef(repositoryPath: string): Promise<string> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
const result = await repo.exec(['rev-parse', '--abbrev-ref', 'HEAD']);
return result.stdout.trim();
}
@command('_git.mergeBranch')
async mergeBranch(repositoryPath: string, branch: string): Promise<string> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
const result = await repo.exec(['merge', branch, '--no-edit']);
return result.stdout.trim();
}
@command('git.init')
async init(skipFolderPrompt = false): Promise<void> {
let repositoryPath: string | undefined = undefined;
@@ -5653,15 +5669,14 @@ export class CommandCenter {
options.modal = false;
break;
default: {
const hint = (err.stderr || err.message || String(err))
const hint = (err.stderr || err.stdout || err.message || String(err))
.replace(/^error: /mi, '')
.replace(/^> husky.*$/mi, '')
.split(/[\r\n]/)
.filter((line: string) => !!line)
[0];
.filter((line: string) => !!line);
message = hint
? l10n.t('Git: {0}', hint)
? l10n.t('Git: {0}', err.stdout ? hint[hint.length - 1] : hint[0])
: l10n.t('Git error');
break;

View File

@@ -183,7 +183,7 @@
"when": "github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/"
}
],
"chat/input/editing/sessionToolbar": [
"chat/input/editing/sessionApplyActions": [
{
"command": "github.createPullRequest",
"group": "navigation",

View File

@@ -259,6 +259,7 @@ export class MenuId {
static readonly ChatModePicker = new MenuId('ChatModePicker');
static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar');
static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar');
static readonly ChatEditingSessionApplySubmenu = new MenuId('ChatEditingSessionApplySubmenu');
static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent');
static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk');
static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell');

View File

@@ -0,0 +1,201 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { toAction } from '../../../../base/common/actions.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { autorun } from '../../../../base/common/observable.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { localize, localize2 } from '../../../../nls.js';
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { URI } from '../../../../base/common/uri.js';
const hasWorktreeAndRepositoryContextKey = new RawContextKey<boolean>('agentSessionHasWorktreeAndRepository', false, {
type: 'boolean',
description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.")
});
const hasAheadCommitsContextKey = new RawContextKey<boolean>('agentSessionHasAheadCommits', false, {
type: 'boolean',
description: localize('agentSessionHasAheadCommits', "True when the active agent session worktree has commits ahead of its upstream.")
});
class ApplyChangesToParentRepoContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'sessions.contrib.applyChangesToParentRepo';
private readonly _gitRepoDisposables = this._register(new MutableDisposable<DisposableStore>());
constructor(
@IContextKeyService contextKeyService: IContextKeyService,
@ISessionsManagementService sessionManagementService: ISessionsManagementService,
@IGitService private readonly gitService: IGitService,
) {
super();
const worktreeAndRepoKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService);
const aheadCommitsKey = hasAheadCommitsContextKey.bindTo(contextKeyService);
this._register(autorun(reader => {
const activeSession = sessionManagementService.activeSession.read(reader);
const hasWorktreeAndRepo = !!activeSession?.worktree && !!activeSession?.repository;
worktreeAndRepoKey.set(hasWorktreeAndRepo);
this._gitRepoDisposables.clear();
if (!hasWorktreeAndRepo || !activeSession?.worktree) {
aheadCommitsKey.set(false);
return;
}
const repoDisposables = this._gitRepoDisposables.value = new DisposableStore();
this.gitService.openRepository(activeSession.worktree).then(repository => {
if (repoDisposables.isDisposed || !repository) {
aheadCommitsKey.set(false);
return;
}
repoDisposables.add(autorun(innerReader => {
const state = repository.state.read(innerReader);
const ahead = state.HEAD?.ahead ?? 0;
aheadCommitsKey.set(ahead > 0);
}));
});
}));
}
}
class ApplyChangesToParentRepoAction extends Action2 {
static readonly ID = 'chatEditing.applyChangesToParentRepo';
constructor() {
super({
id: ApplyChangesToParentRepoAction.ID,
title: localize2('applyChangesToParentRepo', 'Apply Changes to Parent Repo'),
icon: Codicon.desktopDownload,
category: CHAT_CATEGORY,
precondition: ContextKeyExpr.and(
IsSessionsWindowContext,
hasWorktreeAndRepositoryContextKey,
),
menu: [
{
id: MenuId.ChatEditingSessionApplySubmenu,
group: 'navigation',
order: 2,
when: ContextKeyExpr.and(
IsSessionsWindowContext,
hasWorktreeAndRepositoryContextKey,
),
},
],
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const sessionManagementService = accessor.get(ISessionsManagementService);
const commandService = accessor.get(ICommandService);
const notificationService = accessor.get(INotificationService);
const logService = accessor.get(ILogService);
const openerService = accessor.get(IOpenerService);
const productService = accessor.get(IProductService);
const activeSession = sessionManagementService.getActiveSession();
if (!activeSession?.worktree || !activeSession?.repository) {
return;
}
const worktreeRoot = activeSession.worktree;
const repoRoot = activeSession.repository;
const openFolderAction = toAction({
id: 'applyChangesToParentRepo.openFolder',
label: localize('openInVSCode', "Open in VS Code"),
run: () => {
const scheme = productService.quality === 'stable'
? 'vscode'
: productService.quality === 'exploration'
? 'vscode-exploration'
: 'vscode-insiders';
const params = new URLSearchParams();
params.set('windowId', '_blank');
params.set('session', activeSession.resource.toString());
openerService.open(URI.from({
scheme,
authority: Schemas.file,
path: repoRoot.path,
query: params.toString(),
}), { openExternal: true });
}
});
try {
// Get the worktree branch name. Since the worktree and parent repo
// share the same git object store, the parent can directly reference
// this branch for a merge.
const worktreeBranch = await commandService.executeCommand<string>(
'_git.revParseAbbrevRef',
worktreeRoot.fsPath
);
if (!worktreeBranch) {
notificationService.notify({
severity: Severity.Warning,
message: localize('applyChangesNoBranch', "Could not determine worktree branch name."),
});
return;
}
// Merge the worktree branch into the parent repo.
// This is idempotent: if already merged, git says "Already up to date."
// If new commits exist, they're brought in. Handles partial applies naturally.
const result = await commandService.executeCommand('_git.mergeBranch', repoRoot.fsPath, worktreeBranch);
if (!result) {
logService.warn('[ApplyChangesToParentRepo] No result from merge command');
} else {
notificationService.notify({
severity: Severity.Info,
message: typeof result === 'string' && result.startsWith('Already up to date')
? localize('alreadyUpToDate', 'Parent repository is up to date with worktree.')
: localize('applyChangesSuccess', 'Applied changes to parent repository.'),
actions: { primary: [openFolderAction] }
});
}
} catch (err) {
logService.error('[ApplyChangesToParentRepo] Failed to apply changes', err);
notificationService.notify({
severity: Severity.Warning,
message: localize('applyChangesConflict', "Failed to apply changes to parent repo. The parent repo may have diverged — resolve conflicts manually."),
actions: { primary: [openFolderAction] }
});
}
}
}
registerAction2(ApplyChangesToParentRepoAction);
registerWorkbenchContribution2(ApplyChangesToParentRepoContribution.ID, ApplyChangesToParentRepoContribution, WorkbenchPhase.AfterRestored);
// Register the apply submenu in the session changes toolbar
MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, {
submenu: MenuId.ChatEditingSessionApplySubmenu,
title: localize2('applyActions', 'Apply Actions'),
group: 'navigation',
order: 1,
when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges),
});

View File

@@ -1,216 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { toAction } from '../../../../base/common/actions.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { autorun } from '../../../../base/common/observable.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { localize, localize2 } from '../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { generateUnifiedDiff } from '../../../../workbench/contrib/chat/browser/chatRepoInfo.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
import { isEqualOrParent, relativePath } from '../../../../base/common/resources.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { URI } from '../../../../base/common/uri.js';
/**
* Normalizes a URI to the `file` scheme so that path comparisons work
* even when the source URI uses a different scheme (e.g. `github-remote-file`).
*/
function toFileUri(uri: URI): URI {
return uri.scheme === 'file' ? uri : URI.file(uri.path);
}
const hasWorktreeAndRepositoryContextKey = new RawContextKey<boolean>('agentSessionHasWorktreeAndRepository', false, {
type: 'boolean',
description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.")
});
class ApplyToParentRepoContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'sessions.contrib.applyToParentRepo';
constructor(
@IContextKeyService contextKeyService: IContextKeyService,
@ISessionsManagementService sessionManagementService: ISessionsManagementService,
) {
super();
const contextKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService);
this._register(autorun(reader => {
const activeSession = sessionManagementService.activeSession.read(reader);
const hasWorktreeAndRepo = !!activeSession?.worktree && !!activeSession?.repository;
contextKey.set(hasWorktreeAndRepo);
}));
}
}
class ApplyToParentRepoAction extends Action2 {
static readonly ID = 'chatEditing.applyToParentRepo';
constructor() {
super({
id: ApplyToParentRepoAction.ID,
title: localize2('applyToParentRepo', 'Apply to Parent Repo'),
icon: Codicon.desktopDownload,
category: CHAT_CATEGORY,
precondition: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges),
menu: [
{
id: MenuId.ChatEditingSessionChangesToolbar,
group: 'navigation',
order: 4,
when: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges),
},
],
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const sessionManagementService = accessor.get(ISessionsManagementService);
const agentSessionsService = accessor.get(IAgentSessionsService);
const fileService = accessor.get(IFileService);
const notificationService = accessor.get(INotificationService);
const logService = accessor.get(ILogService);
const openerService = accessor.get(IOpenerService);
const productService = accessor.get(IProductService);
const commandService = accessor.get(ICommandService);
const activeSession = sessionManagementService.getActiveSession();
if (!activeSession?.worktree || !activeSession?.repository) {
return;
}
const worktreeRoot = activeSession.worktree;
const repoRoot = activeSession.repository;
const agentSession = agentSessionsService.getSession(activeSession.resource);
const changes = agentSession?.changes;
if (!changes || !(changes instanceof Array)) {
return;
}
// Generate a combined unified diff patch from all changes
const patchParts: string[] = [];
let fileCount = 0;
for (const change of changes) {
try {
const modifiedUri = isIChatSessionFileChange2(change)
? change.modifiedUri ?? change.uri
: change.modifiedUri;
const isDeletion = isIChatSessionFileChange2(change)
? change.modifiedUri === undefined
: false;
const originalUri = change.originalUri;
let relPath: string | undefined;
if (isDeletion) {
if (originalUri && isEqualOrParent(toFileUri(originalUri), worktreeRoot)) {
relPath = relativePath(worktreeRoot, toFileUri(originalUri));
}
} else {
if (isEqualOrParent(toFileUri(modifiedUri), worktreeRoot)) {
relPath = relativePath(worktreeRoot, toFileUri(modifiedUri));
}
}
if (!relPath) {
continue;
}
const changeType: 'added' | 'modified' | 'deleted' = isDeletion
? 'deleted'
: originalUri ? 'modified' : 'added';
const diff = await generateUnifiedDiff(
fileService,
relPath,
originalUri,
modifiedUri,
changeType
);
if (diff) {
patchParts.push(diff);
fileCount++;
}
} catch (err) {
logService.error('[ApplyToParentRepo] Failed to generate diff for change', err);
}
}
if (patchParts.length === 0) {
notificationService.notify({
severity: Severity.Info,
message: localize('applyToParentRepoNoDiffs', "No applicable changes to apply to parent repo."),
});
return;
}
const combinedPatch = patchParts.join('\n') + '\n';
const openFolderAction = toAction({
id: 'applyToParentRepo.openFolder',
label: localize('openInVSCode', "Open in VS Code"),
run: () => {
const scheme = productService.quality === 'stable'
? 'vscode'
: productService.quality === 'exploration'
? 'vscode-exploration'
: 'vscode-insiders';
const params = new URLSearchParams();
params.set('windowId', '_blank');
params.set('session', activeSession.resource.toString());
openerService.open(URI.from({
scheme,
authority: Schemas.file,
path: repoRoot.path,
query: params.toString(),
}), { openExternal: true });
}
});
try {
await commandService.executeCommand('_git.applyPatch', repoRoot.fsPath, combinedPatch);
notificationService.notify({
severity: Severity.Info,
message: fileCount === 1
? localize('applyToParentRepoSuccess1', "Applied 1 file to parent repo.")
: localize('applyToParentRepoSuccessN', "Applied {0} files to parent repo.", fileCount),
actions: { primary: [openFolderAction] }
});
} catch (err) {
logService.error('[ApplyToParentRepo] git apply failed', err);
notificationService.notify({
severity: Severity.Warning,
message: localize('applyToParentRepoConflict', "Failed to apply patch to parent repo. The parent repo may have diverged — resolve conflicts manually."),
actions: { primary: [openFolderAction] }
});
}
}
}
registerAction2(ApplyToParentRepoAction);
registerWorkbenchContribution2(ApplyToParentRepoContribution.ID, ApplyToParentRepoContribution, WorkbenchPhase.AfterRestored);

View File

@@ -576,10 +576,7 @@ export class ChangesViewPane extends ViewPane {
return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel };
}
if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') {
return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' };
}
if (action.id === 'chatEditing.applyToParentRepo') {
return { showIcon: true, showLabel: false, isSecondary: true };
return { showIcon: true, showLabel: true, isSecondary: true };
}
if (action.id === 'chatEditing.synchronizeChanges') {
return { showIcon: true, showLabel: true, isSecondary: true };

View File

@@ -114,6 +114,29 @@
flex: 1;
}
/* ButtonWithDropdown container grows to fill available space */
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown {
flex: 1;
display: flex;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button {
flex: 1;
box-sizing: border-box;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button-dropdown-separator {
flex: 0;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.monaco-dropdown-button {
flex: 0 0 auto;
padding: 4px;
width: auto;
min-width: 0;
border-radius: 0px 4px 4px 0px;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon {
padding: 4px 8px;
font-size: 16px !important;
@@ -134,6 +157,7 @@
.changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-radius: 4px 0px 0px 4px;
}
.changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover {

View File

@@ -208,7 +208,7 @@ import './contrib/sessions/browser/customizationsToolbar.contribution.js';
import './contrib/changesView/browser/changesView.contribution.js';
import './contrib/files/browser/files.contribution.js';
import './contrib/gitSync/browser/gitSync.contribution.js';
import './contrib/applyToParentRepo/browser/applyToParentRepo.contribution.js';
import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js';
import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed
import './contrib/configuration/browser/configuration.contribution.js';

View File

@@ -479,6 +479,12 @@ const apiMenus: IAPIMenu[] = [
description: localize('menus.chatEditingSessionChangesToolbar', "The Chat Editing widget toolbar menu for session changes."),
proposed: 'chatSessionsProvider'
},
{
key: 'chat/input/editing/sessionApplyActions',
id: MenuId.ChatEditingSessionApplySubmenu,
description: localize('menus.chatEditingSessionApplySubmenu', "Submenu for apply actions in the Chat Editing session changes toolbar."),
proposed: 'chatSessionsProvider'
},
{
// TODO: rename this to something like: `chatSessions/item/inline`
key: 'chat/chatSessions',