Implement squash-commit on the git graph history#297071
Implement squash-commit on the git graph history#297071timheuer wants to merge 1 commit intomicrosoft:mainfrom
Conversation
📬 CODENOTIFYThe following users are being notified based on files changed in this PR: @lszomoruMatched files:
|
There was a problem hiding this comment.
Pull request overview
Adds an initial “Squash Commits” capability to the SCM history graph context menu by enabling multi-selection in the SCM History view and wiring a new Git extension command that rewrites commit history into a single commit with an editable message.
Changes:
- Enable multi-selection in the SCM History tree and pass selected history items to context-menu actions.
- Add
git.graph.squashCommitscommand implementation (currently limited to squashing the latest contiguous commits includingHEAD) with an editor-based commit message flow. - Introduce a low-level
commitTree()Git API to support squashing when the selected range includes the root commit.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts | Enables multi-selection and adds a context-menu action runner to forward selected history items. |
| extensions/git/src/repository.ts | Exposes commitTree() on the higher-level Repository wrapper. |
| extensions/git/src/git.ts | Implements git commit-tree plumbing via commitTree(). |
| extensions/git/src/commands.ts | Adds the squash command, selection validation, confirmation UI, and commit message editor flow. |
| extensions/git/package.nls.json | Adds localized title string for “Squash Commits”. |
| extensions/git/package.json | Registers the command and contributes it to the SCM History item context menu. |
| window.showInformationMessage(l10n.t('Edit the commit message and save to continue squash. Close the document to cancel.')); | ||
|
|
||
| const message = await new Promise<string | undefined>(resolve => { | ||
| const disposables: Disposable[] = []; | ||
|
|
||
| const complete = (value: string | undefined) => { | ||
| dispose(disposables); | ||
| resolve(value); | ||
| }; | ||
|
|
||
| disposables.push(workspace.onDidSaveTextDocument(savedDocument => { | ||
| if (savedDocument.uri.toString() === squashMessageUri.toString()) { | ||
| complete(savedDocument.getText()); | ||
| } | ||
| })); | ||
|
|
||
| disposables.push(workspace.onDidCloseTextDocument(closedDocument => { | ||
| if (closedDocument.uri.toString() === squashMessageUri.toString()) { | ||
| complete(undefined); | ||
| } | ||
| })); | ||
| }); |
There was a problem hiding this comment.
openSquashCommitMessageEditor proceeds as soon as the document is saved. With Auto Save enabled (especially afterDelay), the file can be saved while the user is still editing, which can trigger the squash operation prematurely with a partial message.
Consider requiring an explicit user action to continue (e.g., a modal confirmation/command) or waiting until the document is closed and then reading its final contents, rather than treating the first save event as completion.
| try { | ||
| if (!oldestCommitParent) { | ||
| const squashedRootCommit = await repository.commitTree(headCommit, message); | ||
| await repository.reset(squashedRootCommit, true); | ||
| } else { | ||
| await repository.reset(oldestCommitParent); | ||
| await repository.commit(message, {}); | ||
| } |
There was a problem hiding this comment.
The squashed commit will get a new author/committer timestamp ("now") because the implementation uses git commit / git commit-tree without setting dates. Issue #214809 calls out that the squashed commit should use the date of the oldest selected commit.
Consider setting the author/committer dates from oldestCommit.authorDate / oldestCommit.commitDate (e.g., git commit --date=... plus GIT_COMMITTER_DATE, and for commit-tree via GIT_AUTHOR_DATE/GIT_COMMITTER_DATE env) so the resulting commit preserves the expected timeline metadata.
| horizontalScrolling: false, | ||
| multipleSelectionSupport: false | ||
| multipleSelectionSupport: true, | ||
| expandOnlyOnTwistieClick: false |
There was a problem hiding this comment.
Setting expandOnlyOnTwistieClick: false changes the tree interaction model (the default is true when the option is omitted in AbstractTreeOptions). This makes single-click on a node toggle expansion, which is likely to interfere with the new multi-selection workflow (click-to-select can unexpectedly expand/collapse).
Consider keeping the default behavior (omit the option) or setting it to true so expansion requires the twistie, which is also consistent with other SCM trees (e.g. scmRepositoriesViewPane).
| expandOnlyOnTwistieClick: false | |
| expandOnlyOnTwistieClick: true |
| if (action.id !== 'git.graph.squashCommits') { | ||
| return super.runAction(action, context); | ||
| } | ||
|
|
||
| const contextHistoryItem = context as ISCMHistoryItem | undefined; | ||
| const selectedHistoryItems = this._getSelectedHistoryItems(); | ||
| const historyItems = selectedHistoryItems.length > 1 && contextHistoryItem && | ||
| selectedHistoryItems.some(historyItem => historyItem.id === contextHistoryItem.id) | ||
| ? selectedHistoryItems | ||
| : contextHistoryItem ? [contextHistoryItem] : []; | ||
|
|
||
| return this._progressService.withProgress({ location: HISTORY_VIEW_PANE_ID }, async () => { | ||
| await action.run(contextHistoryItem, historyItems); |
There was a problem hiding this comment.
SCMHistoryContextMenuActionRunner hard-codes the Git-specific command id ('git.graph.squashCommits') in the generic SCM history view. This creates a brittle coupling between the workbench SCM UI and the Git extension (e.g., renames/alternate providers), and makes it hard to extend multi-selection behavior for other providers/commands.
Consider introducing a provider-agnostic way to pass multi-selection to commands (for example, a dedicated SCM history context object/argument that includes both the context item and the selection, or a generic mechanism for opt-in multi-selection arguments) so the SCM view doesn’t need to special-case a Git command id.
| if (action.id !== 'git.graph.squashCommits') { | |
| return super.runAction(action, context); | |
| } | |
| const contextHistoryItem = context as ISCMHistoryItem | undefined; | |
| const selectedHistoryItems = this._getSelectedHistoryItems(); | |
| const historyItems = selectedHistoryItems.length > 1 && contextHistoryItem && | |
| selectedHistoryItems.some(historyItem => historyItem.id === contextHistoryItem.id) | |
| ? selectedHistoryItems | |
| : contextHistoryItem ? [contextHistoryItem] : []; | |
| return this._progressService.withProgress({ location: HISTORY_VIEW_PANE_ID }, async () => { | |
| await action.run(contextHistoryItem, historyItems); | |
| const contextHistoryItem = context as ISCMHistoryItem | undefined; | |
| const selectedHistoryItems = this._getSelectedHistoryItems(); | |
| const historyItems = selectedHistoryItems.length > 1 && contextHistoryItem && | |
| selectedHistoryItems.some(historyItem => historyItem.id === contextHistoryItem.id) | |
| ? selectedHistoryItems | |
| : contextHistoryItem ? [contextHistoryItem] : []; | |
| return this._progressService.withProgress({ location: HISTORY_VIEW_PANE_ID }, async () => { | |
| // Always pass the context history item as the first argument (to preserve existing behavior), | |
| // and pass the derived multi-selection as a second argument only for actions that declare it. | |
| const run = (action.run as (...args: unknown[]) => unknown).bind(action); | |
| const args: unknown[] = [contextHistoryItem]; | |
| if (run.length >= 2) { | |
| args.push(historyItems); | |
| } | |
| await run(...args); |
| this._scmProviderCtx = ContextKeys.SCMProvider.bindTo(this.scopedContextKeyService); | ||
| this._scmCurrentHistoryItemRefHasRemote = ContextKeys.SCMCurrentHistoryItemRefHasRemote.bindTo(this.scopedContextKeyService); | ||
| this._scmCurrentHistoryItemRefInFilter = ContextKeys.SCMCurrentHistoryItemRefInFilter.bindTo(this.scopedContextKeyService); | ||
| this._scmHistoryItemMultiSelection = this.scopedContextKeyService.createKey<boolean>('scmHistoryItemMultiSelection', false); |
There was a problem hiding this comment.
This context key is created ad-hoc via scopedContextKeyService.createKey(). In this area, SCM context keys are typically centralized as RawContextKeys on the exported ContextKeys object (see src/vs/workbench/contrib/scm/browser/scmViewPane.ts ContextKeys), then bound via .bindTo().
To stay consistent and avoid stringly-typed keys, consider adding a ContextKeys.SCMHistoryItemMultiSelection RawContextKey and binding it here instead of creating the key inline.
| this._scmHistoryItemMultiSelection = this.scopedContextKeyService.createKey<boolean>('scmHistoryItemMultiSelection', false); | |
| this._scmHistoryItemMultiSelection = ContextKeys.SCMHistoryItemMultiSelection.bindTo(this.scopedContextKeyService); |
Initial draft of implementing a squash commit capability fixiing #214809
2026-02-23_10-36-21.mp4