diff --git a/contributions.json b/contributions.json index 1f828d41554ee..9489b2a708c25 100644 --- a/contributions.json +++ b/contributions.json @@ -4404,6 +4404,36 @@ ] } }, + "gitlens.recomposeBranch": { + "label": "Recompose Commits (Preview)...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" + }, + "gitlens.recomposeBranch:graph": { + "label": "Recompose Commits (Preview)", + "icon": "$(sparkle)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_ai", + "order": 15 + } + ] + } + }, + "gitlens.recomposeBranch:views": { + "label": "Recompose Commits (Preview)", + "icon": "$(sparkle)", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_ai", + "order": 15 + } + ] + } + }, "gitlens.regenerateMarkdownDocument": { "label": "Regenerate", "icon": "$(refresh)", diff --git a/package.json b/package.json index 6541d92cf1f62..786b78a0a7831 100644 --- a/package.json +++ b/package.json @@ -7924,6 +7924,21 @@ "command": "gitlens.quickOpenFileHistory:graphDetails", "title": "Quick Open File History" }, + { + "command": "gitlens.recomposeBranch", + "title": "Recompose Commits (Preview)...", + "category": "GitLens" + }, + { + "command": "gitlens.recomposeBranch:graph", + "title": "Recompose Commits (Preview)", + "icon": "$(sparkle)" + }, + { + "command": "gitlens.recomposeBranch:views", + "title": "Recompose Commits (Preview)", + "icon": "$(sparkle)" + }, { "command": "gitlens.regenerateMarkdownDocument", "title": "Regenerate", @@ -12693,6 +12708,18 @@ "command": "gitlens.quickOpenFileHistory:graphDetails", "when": "false" }, + { + "command": "gitlens.recomposeBranch", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" + }, + { + "command": "gitlens.recomposeBranch:graph", + "when": "false" + }, + { + "command": "gitlens.recomposeBranch:views", + "when": "false" + }, { "command": "gitlens.regenerateMarkdownDocument", "when": "false" @@ -18313,6 +18340,11 @@ "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", "group": "1_gitlens_ai@20" }, + { + "command": "gitlens.recomposeBranch:views", + "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_ai@15" + }, { "command": "gitlens.views.openBranchOnRemote", "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && !listMultiSelection", @@ -24190,6 +24222,11 @@ "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", "group": "1_gitlens_ai@20" }, + { + "command": "gitlens.recomposeBranch:graph", + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_ai@15" + }, { "command": "gitlens.graph.openBranchOnRemote", "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && gitlens:repos:withRemotes", diff --git a/src/commands.ts b/src/commands.ts index 218d6e975fb3d..2e99f96b31daa 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -54,6 +54,7 @@ import './commands/openWorkingFile'; import './commands/patches'; import './commands/quickWizard'; import './commands/rebaseEditor'; +import './commands/recomposeBranch'; import './commands/refreshHover'; import './commands/regenerateMarkdownDocument'; import './commands/remoteProviders'; diff --git a/src/commands/recomposeBranch.ts b/src/commands/recomposeBranch.ts new file mode 100644 index 0000000000000..a9a459be33473 --- /dev/null +++ b/src/commands/recomposeBranch.ts @@ -0,0 +1,94 @@ +import { window } from 'vscode'; +import type { Sources } from '../constants.telemetry'; +import type { Container } from '../container'; +import { CommandQuickPickItem } from '../quickpicks/items/common'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command, executeCommand } from '../system/-webview/command'; +import { getNodeRepoPath } from '../views/nodes/abstract/viewNode'; +import type { WebviewPanelShowCommandArgs } from '../webviews/webviewsController'; +import { GlCommandBase } from './commandBase'; +import type { CommandContext } from './commandContext'; +import { isCommandContextViewNodeHasBranch } from './commandContext.utils'; + +export interface RecomposeBranchCommandArgs { + repoPath?: string; + branchName?: string; + source?: Sources; +} + +@command() +export class RecomposeBranchCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super(['gitlens.recomposeBranch', 'gitlens.recomposeBranch:views']); + } + + protected override preExecute(context: CommandContext, args?: RecomposeBranchCommandArgs): Promise { + if (isCommandContextViewNodeHasBranch(context)) { + args = { ...args }; + args.repoPath = args.repoPath ?? getNodeRepoPath(context.node); + args.branchName = args.branchName ?? context.node.branch.name; + args.source = args.source ?? 'view'; + } + + return this.execute(args); + } + + async execute(args?: RecomposeBranchCommandArgs): Promise { + try { + // Get repository path using picker fallback + const repoPath = + args?.repoPath ?? + (await getBestRepositoryOrShowPicker(this.container, undefined, undefined, 'Recompose Branch'))?.path; + if (!repoPath) return; + + args = { ...args }; + + // Get branch name using picker fallback + let branchName = args.branchName; + if (!branchName) { + const pick = await showReferencePicker(repoPath, 'Recompose Branch', 'Choose a branch to recompose', { + include: ReferencesQuickPickIncludes.Branches, + sort: { branches: { current: true } }, + }); + if (pick == null || pick instanceof CommandQuickPickItem) return; + + if (pick.refType === 'branch') { + branchName = pick.name; + } else { + return; + } + } + + // Validate that the repository exists + const repo = this.container.git.getRepository(repoPath); + if (!repo) { + void window.showErrorMessage('Repository not found'); + return; + } + + // Validate that the branch exists + const branch = await repo.git.branches.getBranch(branchName); + if (!branch) { + void window.showErrorMessage(`Branch '${branchName}' not found`); + return; + } + + // Check if branch is remote-only + if (branch.remote && !branch.upstream) { + void window.showErrorMessage(`Cannot recompose remote-only branch '${branchName}'`); + return; + } + + // Open the composer with branch mode + await executeCommand('gitlens.showComposerPage', undefined, { + repoPath: repoPath, + source: args?.source, + mode: 'preview', + branchName: branchName, + }); + } catch (ex) { + void window.showErrorMessage(`Failed to recompose branch: ${ex}`); + } + } +} diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index 01766eb63712d..8a3497f5aa233 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -248,6 +248,8 @@ export type ContributedCommands = | 'gitlens.quickOpenFileHistory' | 'gitlens.quickOpenFileHistory:commitDetails' | 'gitlens.quickOpenFileHistory:graphDetails' + | 'gitlens.recomposeBranch:graph' + | 'gitlens.recomposeBranch:views' | 'gitlens.regenerateMarkdownDocument' | 'gitlens.restore.file:commitDetails' | 'gitlens.restore.file:graphDetails' @@ -920,6 +922,7 @@ export type ContributedPaletteCommands = | 'gitlens.pullRepositories' | 'gitlens.pushRepositories' | 'gitlens.quickOpenFileHistory' + | 'gitlens.recomposeBranch' | 'gitlens.reset' | 'gitlens.resetViewsLayout' | 'gitlens.revealCommitInView' diff --git a/src/env/node/git/sub-providers/patch.ts b/src/env/node/git/sub-providers/patch.ts index f8e062d2a6fe2..2ac73cf1079bb 100644 --- a/src/env/node/git/sub-providers/patch.ts +++ b/src/env/node/git/sub-providers/patch.ts @@ -10,7 +10,7 @@ import { WorktreeCreateError, } from '../../../../git/errors'; import type { GitPatchSubProvider } from '../../../../git/gitProvider'; -import type { GitCommit } from '../../../../git/models/commit'; +import type { GitCommit, GitCommitIdentityShape } from '../../../../git/models/commit'; import { log } from '../../../../system/decorators/log'; import { Logger } from '../../../../system/logger'; import { getLogScope } from '../../../../system/logger.scope'; @@ -178,7 +178,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { async createUnreachableCommitsFromPatches( repoPath: string, base: string | undefined, - patches: { message: string; patch: string }[], + patches: { message: string; patch: string; author?: GitCommitIdentityShape }[], ): Promise { // Create a temporary index file await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, base); @@ -186,8 +186,8 @@ export class PatchGitSubProvider implements GitPatchSubProvider { const shas: string[] = []; - for (const { message, patch } of patches) { - const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch); + for (const { message, patch, author } of patches) { + const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch, author); shas.push(sha); base = sha; } @@ -201,6 +201,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { base: string | undefined, message: string, patch: string, + author?: GitCommitIdentityShape, ): Promise { const scope = getLogScope(); @@ -221,9 +222,18 @@ export class PatchGitSubProvider implements GitPatchSubProvider { let result = await this.git.exec({ cwd: repoPath, env: env }, 'write-tree'); const tree = result.stdout.trim(); + // Set the author if provided + const commitEnv = author + ? { + ...env, + GIT_AUTHOR_NAME: author.name, + GIT_AUTHOR_EMAIL: author.email || '', + } + : env; + // Create new commit from the tree result = await this.git.exec( - { cwd: repoPath, env: env }, + { cwd: repoPath, env: commitEnv }, 'commit-tree', tree, ...(base ? ['-p', base] : []), diff --git a/src/env/node/git/sub-providers/refs.ts b/src/env/node/git/sub-providers/refs.ts index a5af44046a9d5..6c5961d2e1f28 100644 --- a/src/env/node/git/sub-providers/refs.ts +++ b/src/env/node/git/sub-providers/refs.ts @@ -178,4 +178,21 @@ export class RefsGitSubProvider implements GitRefsSubProvider { ); return result.stdout.trim() || undefined; } + + @log() + async updateReference( + repoPath: string, + ref: string, + newRef: string, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + try { + await this.git.exec({ cwd: repoPath, cancellation: cancellation }, 'update-ref', ref, newRef); + } catch (ex) { + Logger.error(ex, scope); + if (isCancellationError(ex)) throw ex; + } + } } diff --git a/src/env/node/git/sub-providers/status.ts b/src/env/node/git/sub-providers/status.ts index 074455a30c620..754b99a832cc1 100644 --- a/src/env/node/git/sub-providers/status.ts +++ b/src/env/node/git/sub-providers/status.ts @@ -123,7 +123,7 @@ export class StatusGitSubProvider implements GitStatusSubProvider { @log() async hasWorkingChanges( repoPath: string, - options?: { staged?: boolean; unstaged?: boolean; untracked?: boolean }, + options?: { staged?: boolean; unstaged?: boolean; untracked?: boolean; throwOnError?: boolean }, cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -169,6 +169,7 @@ export class StatusGitSubProvider implements GitStatusSubProvider { // Log other errors and return false for graceful degradation Logger.error(ex, scope); setLogScopeExit(scope, ' \u2022 error checking for changes'); + if (options?.throwOnError) throw ex; return false; } } diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 98164e505206c..ef885923df8cf 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -11,7 +11,7 @@ import type { GitUri } from './gitUri'; import type { GitConflictFile } from './models'; import type { GitBlame, GitBlameLine } from './models/blame'; import type { GitBranch } from './models/branch'; -import type { GitCommit, GitCommitStats, GitStashCommit } from './models/commit'; +import type { GitCommit, GitCommitIdentityShape, GitCommitStats, GitStashCommit } from './models/commit'; import type { GitContributor, GitContributorsStats } from './models/contributor'; import type { GitDiff, @@ -554,7 +554,7 @@ export interface GitPatchSubProvider { createUnreachableCommitsFromPatches( repoPath: string, base: string | undefined, - patches: { message: string; patch: string }[], + patches: { message: string; patch: string; author?: GitCommitIdentityShape }[], ): Promise; createEmptyInitialCommit(repoPath: string): Promise; @@ -591,6 +591,7 @@ export interface GitRefsSubProvider { pathOrUri?: string | Uri, cancellation?: CancellationToken, ): Promise; + updateReference(repoPath: string, ref: string, newRef: string, cancellation?: CancellationToken): Promise; } export interface GitRemotesSubProvider { @@ -761,6 +762,8 @@ export interface GitStatusSubProvider { unstaged?: boolean; /** Check for untracked files (default: true) */ untracked?: boolean; + /** Throw errors rather than returning false */ + throwOnError?: boolean; }, cancellation?: CancellationToken, ): Promise; diff --git a/src/plus/integrations/providers/github/sub-providers/refs.ts b/src/plus/integrations/providers/github/sub-providers/refs.ts index 845c7cf9b44b3..926173e861ba3 100644 --- a/src/plus/integrations/providers/github/sub-providers/refs.ts +++ b/src/plus/integrations/providers/github/sub-providers/refs.ts @@ -133,4 +133,14 @@ export class RefsGitSubProvider implements GitRefsSubProvider { ): Promise { return Promise.resolve(true); } + + @log() + updateReference( + _repoPath: string, + _ref: string, + _newRef: string, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(); + } } diff --git a/src/webviews/apps/plus/composer/components/app.ts b/src/webviews/apps/plus/composer/components/app.ts index cb1bf236f650b..869c20a2493bc 100644 --- a/src/webviews/apps/plus/composer/components/app.ts +++ b/src/webviews/apps/plus/composer/components/app.ts @@ -1176,15 +1176,19 @@ export class ComposerApp extends LitElement { } private get canFinishAndCommit(): boolean { - return this.state.commits.length > 0; + return !this.commitsLocked && this.state.commits.length > 0; } private get isPreviewMode(): boolean { return this.state?.mode === 'preview'; } + private get commitsLocked(): boolean { + return this.state?.recompose?.enabled === true && this.state?.recompose?.locked === true; + } + private get canCombineCommits(): boolean { - return !this.isPreviewMode; + return !this.isPreviewMode && !this.commitsLocked; } private get showHistoryButtons(): boolean { @@ -1192,7 +1196,15 @@ export class ComposerApp extends LitElement { } private get canMoveHunks(): boolean { - return !this.isPreviewMode; + return !this.isPreviewMode && !this.commitsLocked; + } + + private get canEditCommitMessages(): boolean { + return !this.commitsLocked; + } + + private get canReorderCommits(): boolean { + return !this.isPreviewMode && !this.commitsLocked; } private get isReadyToFinishAndCommit(): boolean { @@ -1225,10 +1237,6 @@ export class ComposerApp extends LitElement { return availableHunks; } - private get canEditCommitMessages(): boolean { - return true; // Always allowed - } - private get canGenerateCommitMessages(): boolean { return this.aiEnabled; // Allowed in both modes if AI is enabled } @@ -1580,6 +1588,7 @@ export class ComposerApp extends LitElement { .canCombineCommits=${this.canCombineCommits} .canMoveHunks=${this.canMoveHunks} .canGenerateCommitsWithAI=${this.canGenerateCommitsWithAI} + .canReorderCommits=${this.canReorderCommits} .isPreviewMode=${this.isPreviewMode} .baseCommit=${this.state.baseCommit} .repoName=${this.state.baseCommit?.repoName ?? this.state.repositoryState?.current.name} @@ -1591,6 +1600,7 @@ export class ComposerApp extends LitElement { .compositionFeedback=${this.compositionFeedback} .compositionSessionId=${this.compositionSessionId} .isReadyToCommit=${this.isReadyToFinishAndCommit} + .recompose=${this.state.recompose} @commit-select=${(e: CustomEvent) => this.selectCommit(e.detail.commitId, e.detail.multiSelect)} @unassigned-select=${(e: CustomEvent) => this.selectUnassignedSection(e.detail.section)} @combine-commits=${this.combineSelectedCommits} diff --git a/src/webviews/apps/plus/composer/components/commit-item.ts b/src/webviews/apps/plus/composer/components/commit-item.ts index fe7ae54219d31..79b2752d16b84 100644 --- a/src/webviews/apps/plus/composer/components/commit-item.ts +++ b/src/webviews/apps/plus/composer/components/commit-item.ts @@ -83,6 +83,9 @@ export class CommitItem extends LitElement { @property({ type: Boolean }) isPreviewMode = false; + @property({ type: Boolean }) + isRecomposeLocked = false; + @property({ type: Boolean }) first = false; @@ -123,7 +126,9 @@ export class CommitItem extends LitElement {
${when( - !this.hasUsedAutoCompose, + !this.hasUsedAutoCompose && !this.isRecomposeLocked, () => html`

Auto-Compose Commits with AI (Preview)

@@ -1043,6 +1070,16 @@ export class CommitsPanel extends LitElement {

`, )} + ${when( + this.isRecomposeLocked, + () => html` +

Recompose Commits with AI (Preview)

+

+ Let AI reorganize work into logical commits with clear messages and descriptions that help + reviewers. +

+ `, + )} ${this.generating ? 'Generating Commits...' - : this.hasUsedAutoCompose + : this.hasUsedAutoCompose || this.isRecomposeLocked ? 'Recompose Commits' : 'Auto-Compose Commits'} @@ -1140,8 +1177,8 @@ export class CommitsPanel extends LitElement { if (disabled) { return html`
-

Finish & Commit

-

New commits will be added to your current branch.

+

${this.finishHeaderText}

+

${this.finishDescriptionText}

Create Commits @@ -1152,16 +1189,28 @@ export class CommitsPanel extends LitElement { `; } + // Special case for recompose locked mode - only show Cancel button + if (this.isRecomposeLocked) { + return html` +
+ + Cancel + +
+ `; + } + return html`
${when( this.selectedCommitIds.size > 1 && !this.isPreviewMode, () => html` -

Finish & Commit

+

${this.finishHeaderText}

- New commits will be added to your current branch and a stash will be created with your - original changes. + ${this.recompose?.enabled + ? 'The branch will be updated with the new commit structure.' + : 'New commits will be added to your current branch.'}

`, () => html` -

Finish & Commit

+

${this.finishHeaderText}

- ${this.isReadyToCommit - ? 'New commits will be added to your current branch.' - : 'Commit the changes in this draft.'} + ${this.isReadyToCommit ? this.finishDescriptionText : 'Commit the changes in this draft.'}

@@ -1261,18 +1308,22 @@ export class CommitsPanel extends LitElement { return html`
- - ${when(!this.hasUsedAutoCompose, () => this.renderAutoComposeContainer())} + + ${when(!this.hasUsedAutoCompose && !this.isRecomposeLocked, () => + this.renderAutoComposeContainer(), + )}
- ${this.hasUsedAutoCompose + ${this.hasUsedAutoCompose && !this.isRecomposeLocked ? this.renderCompositionSummarySection() - : this.renderUnassignedSection()} + : !this.isRecomposeLocked + ? this.renderUnassignedSection() + : ''} -

Draft Commits

+

${this.isRecomposeLocked ? 'Commits' : 'Draft Commits'}

${when( - !this.isPreviewMode && this.shouldShowNewCommitZone, + !this.isPreviewMode && this.canReorderCommits && this.shouldShowNewCommitZone, () => html`
@@ -1299,6 +1350,7 @@ export class CommitsPanel extends LitElement { .selected=${this.selectedCommitId === commit.id} .multiSelected=${this.selectedCommitIds.has(commit.id)} .isPreviewMode=${this.isPreviewMode} + .isRecomposeLocked=${this.isRecomposeLocked} ?first=${i === 0} ?last=${i === this.commits.length - 1 && !this.baseCommit} @click=${(e: MouseEvent) => this.dispatchCommitSelect(commit.id, e)} @@ -1339,8 +1391,8 @@ export class CommitsPanel extends LitElement { `, )}
- - ${when(this.hasUsedAutoCompose, () => this.renderAutoComposeContainer())} + + ${when(this.hasUsedAutoCompose || this.isRecomposeLocked, () => this.renderAutoComposeContainer())}
${this.renderFinishCommitSection()}
diff --git a/src/webviews/apps/plus/composer/components/composer.css.ts b/src/webviews/apps/plus/composer/components/composer.css.ts index 6d943fd238d0a..1cd384381465f 100644 --- a/src/webviews/apps/plus/composer/components/composer.css.ts +++ b/src/webviews/apps/plus/composer/components/composer.css.ts @@ -173,6 +173,13 @@ export const composerItemCommitStyles = css` border-left-style: solid; } + .composer-item.is-recompose-locked .composer-item__commit::after { + border-style: solid; + } + .composer-item.is-recompose-locked .composer-item__commit::before { + border-left-style: solid; + } + .composer-item__commit.is-empty::before, .composer-item__commit.is-empty::after { display: none; diff --git a/src/webviews/apps/plus/composer/components/details-panel.ts b/src/webviews/apps/plus/composer/components/details-panel.ts index 9853d89669350..9e6395c2797a8 100644 --- a/src/webviews/apps/plus/composer/components/details-panel.ts +++ b/src/webviews/apps/plus/composer/components/details-panel.ts @@ -243,6 +243,12 @@ export class DetailsPanel extends LitElement { @property({ type: Boolean }) hasChanges: boolean = true; + @property({ type: Boolean }) + canEditCommitMessages: boolean = true; + + @property({ type: Boolean }) + canMoveHunks: boolean = true; + @state() private defaultFilesExpanded: boolean = true; @@ -262,7 +268,8 @@ export class DetailsPanel extends LitElement { if ( changedProperties.has('selectedCommits') || changedProperties.has('hunks') || - changedProperties.has('isPreviewMode') + changedProperties.has('isPreviewMode') || + changedProperties.has('canMoveHunks') ) { this.initializeHunksSortable(); this.setupAutoScroll(); @@ -287,8 +294,8 @@ export class DetailsPanel extends LitElement { private initializeHunksSortable() { this.destroyHunksSortables(); - // Don't initialize sortable in AI preview mode - if (this.isPreviewMode) { + // Don't initialize sortable in AI preview mode or when hunk moving is disabled + if (this.isPreviewMode || !this.canMoveHunks) { return; } @@ -665,7 +672,7 @@ export class DetailsPanel extends LitElement { ?generating=${this.generatingCommitMessage === commit.id} ?ai-enabled=${this.aiEnabled} .aiDisabledReason=${this.aiDisabledReason} - editable + ?editable=${this.canEditCommitMessages} @message-change=${(e: CustomEvent) => this.handleCommitMessageChange(commit.id, e.detail.message)} @generate-commit-message=${(e: CustomEvent) => this.handleGenerateCommitMessage(commit.id, e)} > diff --git a/src/webviews/apps/plus/composer/stateProvider.ts b/src/webviews/apps/plus/composer/stateProvider.ts index 3987934815c57..0113c1681dc86 100644 --- a/src/webviews/apps/plus/composer/stateProvider.ts +++ b/src/webviews/apps/plus/composer/stateProvider.ts @@ -58,12 +58,18 @@ export class ComposerStateProvider extends StateProviderBase ({ + hunks: (msg.params.hunks ?? this._state.hunks).map(hunk => ({ ...hunk, assigned: true, })), hasUsedAutoCompose: true, timestamp: Date.now(), + recompose: this._state.recompose?.enabled + ? { + ...this._state.recompose, + locked: false, + } + : this._state.recompose, }; (this as any)._state = updatedState; @@ -143,6 +149,12 @@ export class ComposerStateProvider extends StateProviderBase { + this._currentRepository = repo; + this._hunks = hunks; + + const safetyState = await createSafetyState(repo, diffs, baseCommit?.sha, headCommitSha, branchName); + this._safetyState = safetyState; + if (branchName || (baseCommit && headCommitSha)) { + this._recompose = { + enabled: true, + branchName: branchName, + locked: true, // Initially locked - will be unlocked after auto-compose + }; + } + + const aiEnabled = this.getAiEnabled(); + const aiModel = await this.container.ai.getModel( + { silent: true }, + { source: 'composer', correlationId: this.host.instanceId }, + ); + + const onboardingDismissed = this.isOnboardingDismissed(); + const onboardingStepReached = this.getOnboardingStepReached(); + + // Update context + this._context.diff.files = new Set(hunks.map(h => h.fileName)).size; + this._context.diff.hunks = hunks.length; + this._context.diff.lines = hunks.reduce((total, hunk) => total + hunk.content.split('\n').length - 1, 0); + this._context.commits.initialCount = 0; + this._context.ai.enabled.org = aiEnabled.org; + this._context.ai.enabled.config = aiEnabled.config; + this._context.ai.model = aiModel; + this._context.onboarding.dismissed = onboardingDismissed; + this._context.onboarding.stepReached = onboardingStepReached; + this._context.source = source; + this._context.mode = mode; + this._context.warnings.workingDirectoryChanged = false; + this._context.warnings.indexChanged = false; + this._context.sessionStart = new Date().toISOString(); + this.sendTelemetryEvent(isReload ? 'composer/reloaded' : 'composer/loaded'); + + return { + ...this.initialState, + hunks: hunks, + baseCommit: baseCommit ?? null, + commits: commits, + aiEnabled: aiEnabled, + ai: { + model: aiModel, + }, + hasChanges: commits.length > 0, + mode: mode, + onboardingDismissed: onboardingDismissed, + workingDirectoryHasChanged: false, + indexHasChanged: false, + repositoryState: this.getRepositoryState(), + recompose: this._recompose ?? null, + }; + } + + private async initializeStateAndContextFromWorkingDirectory( repo: Repository, includedUnstagedChanges?: boolean, mode: 'experimental' | 'preview' = 'preview', @@ -316,7 +407,7 @@ export class ComposerWebviewProvider implements WebviewProvider h.source === 'staged').map(h => h.index); @@ -370,64 +457,140 @@ export class ComposerWebviewProvider implements WebviewProvider h.fileName)).size; - this._context.diff.hunks = hunks.length; - this._context.diff.lines = hunks.reduce((total, hunk) => total + hunk.content.split('\n').length - 1, 0); this._context.diff.staged = hasStagedChanges; this._context.diff.unstaged = hasUnstagedChanges; - this._context.commits.initialCount = 0; - this._context.ai.enabled.org = aiEnabled.org; - this._context.ai.enabled.config = aiEnabled.config; - this._context.ai.model = aiModel; - this._context.onboarding.dismissed = onboardingDismissed; - this._context.onboarding.stepReached = onboardingStepReached; - this._context.source = source; - this._context.mode = mode; - this._context.warnings.workingDirectoryChanged = false; - this._context.warnings.indexChanged = false; - this._context.sessionStart = new Date().toISOString(); - this.sendTelemetryEvent(isReload ? 'composer/reloaded' : 'composer/loaded'); + this._context.diff.commits = false; // Subscribe to repository changes for working directory monitoring this.subscribeToRepository(repo); - return { - ...this.initialState, - hunks: hunks, - baseCommit: baseCommit + return this.initializeStateAndContext( + repo, + hunks, + commits, + diffs, + baseCommit ? { sha: baseCommit.sha, message: baseCommit.message ?? '', repoName: repo.name, branchName: currentBranch?.name ?? 'main', } - : null, - commits: commits, - aiEnabled: aiEnabled, - ai: { - model: aiModel, - }, - hasChanges: hasChanges, - mode: mode, - onboardingDismissed: onboardingDismissed, - workingDirectoryHasChanged: false, - indexHasChanged: false, - repositoryState: this.getRepositoryState(), + : undefined, + undefined, + undefined, + mode, + source, + isReload, + ); + } + + private async initializeStateAndContextFromBranch( + repo: Repository, + branchName: string, + mode: 'experimental' | 'preview' = 'preview', + source?: Sources, + isReload?: boolean, + ): Promise { + // Get the branch + const branch = await repo.git.branches.getBranch(branchName); + if (!branch) { + return { + ...this.initialState, + loadingError: `Branch '${branchName}' not found.`, + }; + } + + // Get the merge target for the branch with recursive resolution + let mergeTargetName: string | undefined; + let currentMergeTargetBranchName = branchName; + let currentMergeTargetBranch = branch; + const visitedBranches = new Set(); + let attempts = 0; + const maxAttempts = 10; + + while (attempts < maxAttempts) { + attempts++; + + // Prevent infinite loops by tracking visited branches + if (visitedBranches.has(currentMergeTargetBranchName)) { + break; + } + visitedBranches.add(currentMergeTargetBranchName); + + const mergeTargetNameResult = await getBranchMergeTargetName(this.container, currentMergeTargetBranch); + if (!mergeTargetNameResult.paused && mergeTargetNameResult.value) { + mergeTargetName = mergeTargetNameResult.value; + + // Get branch commits to check if we have unique commits + const branchData = await getBranchCommits(this.container, repo, branchName, mergeTargetName); + if (branchData && branchData.commits.length > 0) { + // Found unique commits, use this merge target + const { commits: branchCommits, baseCommit, headCommitSha } = branchData; + + // Create composer commits and hunks from branch commits + const composerData = await createComposerCommitsFromGitCommits(repo, branchCommits); + if (!composerData) { + return { + ...this.initialState, + loadingError: `Failed to process commits for branch '${branchName}'.`, + }; + } + + const { commits, hunks } = composerData; + const diffs = (await getComposerDiffs(repo, { baseSha: baseCommit.sha, headSha: headCommitSha }))!; + + // Return successful state with found commits + return this.initializeStateAndContext( + repo, + hunks, + commits, + diffs, + { + sha: baseCommit.sha, + message: baseCommit.message, + repoName: repo.name, + branchName: branchName, + }, + headCommitSha, + currentMergeTargetBranchName, + mode, + source, + isReload, + ); + } + + // No unique commits found, try to resolve the merge target recursively + // Get the branch that the current merge target points to + const targetBranch = await repo.git.branches.getBranch(mergeTargetName); + if (!targetBranch) { + // Can't find the target branch, stop here + break; + } + + // Check if the target branch name is the same as current branch (circular reference) + if (targetBranch.name === currentMergeTargetBranchName) { + break; + } + + // Move to the target branch and try again + currentMergeTargetBranchName = targetBranch.name; + currentMergeTargetBranch = targetBranch; + } else { + // No merge target found or paused, stop here + break; + } + } + + // If we get here, we couldn't find unique commits after trying all merge targets or reaching max attempts + return { + ...this.initialState, + loadingError: mergeTargetName + ? `Branch '${branchName}' has no unique commits against any resolved merge target.` + : `Unable to determine merge target for branch '${branchName}'.`, }; } @@ -526,13 +689,21 @@ export class ComposerWebviewProvider implements WebviewProvider m.index === index)!, assigned: true }); + + if (this._recompose?.enabled && this._safetyState?.hashes.commits) { + // In recompose mode, we need to break down the commit history and use the combined diff to generate new hunks + // before sending them off to the AI service to compose new commits + const combinedDiff = await calculateCombinedDiffBetweenCommits( + this._currentRepository!, + this._safetyState.baseSha!, + this._safetyState.headSha!, + ); + + const combinedHunks = createHunksFromDiffs(combinedDiff!.contents); + for (const hunk of combinedHunks) { + const { author, coAuthors } = getAuthorAndCoAuthorsForCombinedDiffHunk(this._hunks, hunk); + hunk.author = author; + hunk.coAuthors = coAuthors.length ? coAuthors : undefined; + hunks.push({ ...hunk, assigned: true }); + } + this._hunks = hunks; + } else { + // Working directory mode: use existing hunks + for (const index of params.hunkIndices) { + hunks.push({ ...this._hunks.find(m => m.index === index)!, assigned: true }); + } } const existingCommits = params.commits.map(commit => ({ @@ -874,7 +1065,17 @@ export class ComposerWebviewProvider implements WebviewProvider f.path); if (untrackedPaths?.length) { try { - workingTreeDiffs = await getWorkingTreeDiffs(repo); + diffsWithUntracked = await getComposerDiffs(repo); await repo.git.staging?.stageFiles(untrackedPaths); } catch {} } @@ -1108,7 +1309,7 @@ export class ComposerWebviewProvider implements WebviewProvider { // Mode controls mode: 'experimental' | 'preview'; // experimental = normal mode, preview = locked AI preview mode + recompose: { + enabled: boolean; // true if composer is in recompose mode + branchName?: string; // name of the branch being recomposed + locked: boolean; // true if commits are locked (cannot be reordered/edited) + } | null; // AI settings aiEnabled: { @@ -135,6 +146,7 @@ export const initialState: Omit> = workingDirectoryHasChanged: false, indexHasChanged: false, mode: 'preview', + recompose: null, aiEnabled: { org: false, config: false, @@ -154,6 +166,7 @@ export interface ComposerContext { lines: number; staged: boolean; unstaged: boolean; + commits: boolean; unstagedIncluded: boolean; }; commits: { @@ -226,6 +239,7 @@ export const baseContext: ComposerContext = { lines: 0, staged: false, unstaged: false, + commits: false, unstagedIncluded: false, }, commits: { @@ -456,6 +470,7 @@ export interface ReloadComposerParams { export interface DidGenerateCommitsParams { commits: ComposerCommit[]; + hunks?: ComposerHunk[]; } export interface DidGenerateCommitMessageParams { diff --git a/src/webviews/plus/composer/registration.ts b/src/webviews/plus/composer/registration.ts index 9a03ba13405a0..908a9033b3e4d 100644 --- a/src/webviews/plus/composer/registration.ts +++ b/src/webviews/plus/composer/registration.ts @@ -11,6 +11,7 @@ export interface ComposerCommandArgs { source?: Sources; mode?: 'experimental' | 'preview'; includedUnstagedChanges?: boolean; + branchName?: string; } export type ComposerWebviewShowingArgs = [ComposerCommandArgs]; diff --git a/src/webviews/plus/composer/utils/composer.utils.ts b/src/webviews/plus/composer/utils/composer.utils.ts index c79198d0bbfeb..1b56d225a46e7 100644 --- a/src/webviews/plus/composer/utils/composer.utils.ts +++ b/src/webviews/plus/composer/utils/composer.utils.ts @@ -1,4 +1,6 @@ import { sha256 } from '@env/crypto'; +import type { Container } from '../../../../container'; +import type { GitCommit, GitCommitIdentityShape } from '../../../../git/models/commit'; import type { GitDiff, ParsedGitDiff } from '../../../../git/models/diff'; import type { Repository } from '../../../../git/models/repository'; import { uncommitted, uncommittedStaged } from '../../../../git/models/revision'; @@ -127,18 +129,138 @@ export function createCombinedDiffForCommit(hunks: ComposerHunk[]): { return { patch: commitPatch, filePatches: filePatches }; } +// Given a group of hunks assigned to a single commit, each with their own author and co-authors, determine a single author and co-authors list for the commit +// based on the amount of changes made by each author, measured in additions + deletions +function getAuthorAndCoAuthorsForCommit(commitHunks: ComposerHunk[]): { + author: GitCommitIdentityShape | undefined; + coAuthors: GitCommitIdentityShape[]; +} { + // Each hunk may or may not have an author. Determine the primary author based on the hunk with the largest diff, then assign the rest as co-authors. + // If there is a tie for largest diff, use the first one. + const authorContributionWeights = new Map(); + const coAuthors = new Map(); + for (const hunk of commitHunks) { + if (hunk.author == null) continue; + coAuthors.set(hunk.author.name, hunk.author); + hunk.coAuthors?.forEach(coAuthor => coAuthors.set(coAuthor.name, coAuthor)); + authorContributionWeights.set( + hunk.author.name, + (authorContributionWeights.get(hunk.author.name) ?? 0) + hunk.additions + hunk.deletions, + ); + } + + let primary: GitCommitIdentityShape | undefined; + let primaryScore = 0; + for (const [author, score] of authorContributionWeights.entries()) { + if (primary == null || score > primaryScore) { + primary = coAuthors.get(author); + primaryScore = score; + } + } + + // Remove the primary author from the co-authors, if present + if (primary != null) { + coAuthors.delete(primary.name); + } + + return { author: primary, coAuthors: [...coAuthors.values()] }; +} + +function overlap(range1: { start: number; count: number }, range2: { start: number; count: number }): number { + const end1 = range1.start + range1.count; + const end2 = range2.start + range2.count; + const overlapStart = Math.max(range1.start, range2.start); + const overlapEnd = Math.min(end1, end2); + return Math.max(0, overlapEnd - overlapStart); +} + +// Calculates a similarity score between two hunks that touch the same file, based on the overlap between the lines in their hunk headers +function getHunkSimilarityValue(hunk1: ComposerHunk, hunk2: ComposerHunk): number { + const oldRange1 = hunk1.hunkHeader.match(/@@ -(\d+),(\d+)/); + const newRange1 = hunk1.hunkHeader.match(/@@ -\d+,\d+ \+(\d+),(\d+)/); + const oldRange2 = hunk2.hunkHeader.match(/@@ -(\d+),(\d+)/); + const newRange2 = hunk2.hunkHeader.match(/@@ -\d+,\d+ \+(\d+),(\d+)/); + if (oldRange1 == null || newRange1 == null || oldRange2 == null || newRange2 == null) { + return 0; + } + return ( + overlap( + { start: parseInt(oldRange1[1], 10), count: parseInt(oldRange1[2], 10) }, + { start: parseInt(oldRange2[1], 10), count: parseInt(oldRange2[2], 10) }, + ) + + overlap( + { start: parseInt(newRange1[1], 10), count: parseInt(newRange1[2], 10) }, + { start: parseInt(newRange2[1], 10), count: parseInt(newRange2[2], 10) }, + ) + ); +} + +// Given an array of hunks representing commit history between two commits, and a hunk from their combined diff, determine the author and co-authors of the +// combined diff hunk based on similarity to the commit hunks +export function getAuthorAndCoAuthorsForCombinedDiffHunk( + commitHunks: ComposerHunk[], + combinedDiffHunk: ComposerHunk, +): { author: GitCommitIdentityShape | undefined; coAuthors: GitCommitIdentityShape[] } { + const matches = commitHunks.filter(commitHunk => { + return ( + commitHunk.author != null && + commitHunk.fileName === combinedDiffHunk.fileName && + (!combinedDiffHunk.isRename || commitHunk.isRename === combinedDiffHunk.isRename) + ); + }); + + const similarityByHunkAuthor = new Map(); + const coAuthors = new Map(); + let maxSimilarity = 0; + let primaryAuthor: GitCommitIdentityShape | undefined; + for (const commitHunk of matches) { + coAuthors.set(commitHunk.author!.name, commitHunk.author!); + commitHunk.coAuthors?.forEach(coAuthor => coAuthors.set(coAuthor.name, coAuthor)); + let similarity = getHunkSimilarityValue(commitHunk, combinedDiffHunk); + if (similarityByHunkAuthor.has(commitHunk.author!.name)) { + similarity += similarityByHunkAuthor.get(commitHunk.author!.name)!; + } + + similarityByHunkAuthor.set(commitHunk.author!.name, similarity); + if (primaryAuthor == null || similarity > maxSimilarity) { + maxSimilarity = similarity; + primaryAuthor = commitHunk.author; + } + } + + // Remove the primary author from the co-authors, if present + if (primaryAuthor != null) { + coAuthors.delete(primaryAuthor.name); + } + + return { author: primaryAuthor, coAuthors: [...coAuthors.values()] }; +} + export function convertToComposerDiffInfo( commits: ComposerCommit[], hunks: ComposerHunk[], -): Array<{ message: string; explanation?: string; filePatches: Map; patch: string }> { +): Array<{ + message: string; + explanation?: string; + filePatches: Map; + patch: string; + author?: GitCommitIdentityShape; +}> { return commits.map(commit => { const { patch, filePatches } = createCombinedDiffForCommit(getHunksForCommit(commit, hunks)); + const commitHunks = getHunksForCommit(commit, hunks); + const { author, coAuthors } = getAuthorAndCoAuthorsForCommit(commitHunks); + let message = commit.message; + if (coAuthors.length > 0) { + message += `\n${coAuthors.map(a => `\nCo-authored-by: ${a.name} <${a.email}>`).join()}`; + } return { - message: commit.message, + message: message, explanation: commit.aiExplanation, filePatches: filePatches, patch: patch, + author: author, }; }); } @@ -195,8 +317,10 @@ export function createHunksFromDiffs(stagedDiffContent?: string, unstagedDiffCon /** Converts @type {ParsedGitDiff} output to @type {ComposerHunk}'s */ function convertDiffToComposerHunks( diff: ParsedGitDiff, - source: 'staged' | 'unstaged', + source: 'staged' | 'unstaged' | 'commits', startingCount: number, + author?: GitCommitIdentityShape, + coAuthors?: GitCommitIdentityShape[], ): { hunks: ComposerHunk[]; count: number } { const hunks: ComposerHunk[] = []; let counter = startingCount; @@ -237,6 +361,8 @@ function convertDiffToComposerHunks( source: source, assigned: false, isRename: file.metadata.renamedOrCopied !== false, + author: author, + coAuthors: coAuthors, }; hunks.push(composerHunk); @@ -260,6 +386,8 @@ function convertDiffToComposerHunks( source: source, assigned: false, isRename: false, + author: author, + coAuthors: coAuthors, }; hunks.push(composerHunk); @@ -289,13 +417,27 @@ function calculateHunkStats(content: string): { additions: number; deletions: nu /* Validation Utils */ /** Gets the current staged and unstaged diffs for safety validation */ -export interface WorkingTreeDiffs { +export interface ComposerDiffs { staged: GitDiff | undefined; unstaged: GitDiff | undefined; + commits: GitDiff | undefined; unified: GitDiff | undefined; } -export async function getWorkingTreeDiffs(repo: Repository): Promise { +export async function getComposerDiffs( + repo: Repository, + commits?: { baseSha: string; headSha: string }, +): Promise { + if (commits) { + const commitDiffs = await calculateCombinedDiffBetweenCommits(repo, commits.baseSha, commits.headSha); + + return { + staged: undefined, + unstaged: undefined, + commits: commitDiffs, + unified: commitDiffs, + }; + } const [stagedDiffResult, unstagedDiffResult, unifiedDiffResult] = await Promise.allSettled([ // Get staged diff (index vs HEAD) repo.git.diff.getDiff?.(uncommittedStaged), @@ -309,6 +451,7 @@ export async function getWorkingTreeDiffs(repo: Repository): Promise { return { repoPath: repo.path, headSha: headSha ?? null, + baseSha: baseSha ?? null, + branchName: branchName, hashes: { staged: diffs.staged?.contents ? await sha256(diffs.staged.contents) : null, unstaged: diffs.unstaged?.contents ? await sha256(diffs.unstaged.contents) : null, unified: diffs.unified?.contents ? await sha256(diffs.unified.contents) : null, + commits: diffs.commits?.contents ? await sha256(diffs.commits.contents) : null, }, }; } @@ -339,7 +487,7 @@ export async function validateSafetyState( repo: Repository, safetyState: ComposerSafetyState, hunksBeingCommitted?: ComposerHunk[], - workingTreeDiffs?: WorkingTreeDiffs, + diffs?: ComposerDiffs, ): Promise<{ isValid: boolean; errors: string[] }> { const errors: string[] = []; @@ -350,32 +498,65 @@ export async function validateSafetyState( } // 2. Check HEAD SHA - const currentHeadCommit = await repo.git.commits.getCommit('HEAD'); - const currentHeadSha = currentHeadCommit?.sha ?? null; - if (currentHeadSha !== safetyState.headSha) { - errors.push(`HEAD commit changed from "${safetyState.headSha}" to "${currentHeadSha}"`); + if (safetyState.branchName) { + const branch = await repo.git.branches.getBranch(safetyState.branchName); + if (branch?.sha !== safetyState.headSha) { + errors.push(`HEAD commit changed from "${safetyState.headSha}" to "${branch?.sha}"`); + } + } else { + const currentHeadCommit = await repo.git.commits.getCommit('HEAD'); + const currentHeadCommitSha = currentHeadCommit?.sha ?? null; + if (currentHeadCommitSha !== safetyState.baseSha) { + errors.push(`HEAD commit changed from "${safetyState.baseSha}" to "${currentHeadCommitSha}"`); + } } // 2. Smart diff validation - only check diffs for sources being committed if (hunksBeingCommitted?.length) { - const { staged, unstaged /*, unified*/ } = workingTreeDiffs ?? (await getWorkingTreeDiffs(repo)); - - const hashes = { - staged: staged?.contents ? await sha256(staged.contents) : null, - unstaged: unstaged?.contents ? await sha256(unstaged.contents) : null, - // unified: unified?.contents ? await sha256(unified.contents) : null, - }; - - // Check if any hunks from staged source are being committed - const hasStagedHunks = hunksBeingCommitted.some(h => h.source === 'staged'); - if (hasStagedHunks && hashes.staged !== safetyState.hashes.staged) { - errors.push('Staged changes have been modified since composer opened'); - } + // Check if this is branch mode (has commits hash) + if (safetyState.hashes.commits) { + if (safetyState.baseSha === null) { + return { isValid: false, errors: ['Base commit is null'] }; + } + + if (safetyState.headSha === null) { + return { isValid: false, errors: ['Head commit is null'] }; + } + + const combinedDiff = await calculateCombinedDiffBetweenCommits( + repo, + safetyState.baseSha, + safetyState.headSha, + ); + if (!combinedDiff?.contents) { + return { isValid: false, errors: ['Failed to calculate combined diff'] }; + } + + if ((await sha256(combinedDiff.contents)) !== safetyState.hashes.commits) { + errors.push('Branch changes have been modified since composer opened'); + } + } else { + // Working directory mode: validate staged/unstaged changes + const { staged, unstaged /*, unified*/ } = diffs ?? + (await getComposerDiffs(repo)) ?? { staged: undefined, unstaged: undefined, unified: undefined }; + + const hashes = { + staged: staged?.contents ? await sha256(staged.contents) : null, + unstaged: unstaged?.contents ? await sha256(unstaged.contents) : null, + // unified: unified?.contents ? await sha256(unified.contents) : null, + }; - // Check if any hunks from unstaged source are being committed - const hasUnstagedHunks = hunksBeingCommitted.some(h => h.source === 'unstaged'); - if (hasUnstagedHunks && hashes.unstaged !== safetyState.hashes.unstaged) { - errors.push('Unstaged changes have been modified since composer opened'); + // Check if any hunks from staged source are being committed + const hasStagedHunks = hunksBeingCommitted.some(h => h.source === 'staged'); + if (hasStagedHunks && hashes.staged !== safetyState.hashes.staged) { + errors.push('Staged changes have been modified since composer opened'); + } + + // Check if any hunks from unstaged source are being committed + const hasUnstagedHunks = hunksBeingCommitted.some(h => h.source === 'unstaged'); + if (hasUnstagedHunks && hashes.unstaged !== safetyState.hashes.unstaged) { + errors.push('Unstaged changes have been modified since composer opened'); + } } } @@ -393,5 +574,178 @@ export function validateResultingDiff( includeUnstagedChanges: boolean, ): boolean { const { hashes } = safetyState; + + if (hashes.commits) { + return diffHash === hashes.commits; + } + + // Working directory mode: validate against staged/unified hash return diffHash === (includeUnstagedChanges ? hashes.unified : hashes.staged); } + +/** + * Gets commits that are unique to a branch by finding the merge base and getting commits between branch head and merge base + */ +export async function getBranchCommits( + _container: Container, + repo: Repository, + branchName: string, + mergeTargetName?: string, +): Promise<{ commits: GitCommit[]; baseCommit: { sha: string; message: string }; headCommitSha: string } | undefined> { + try { + // Get the branch + const branch = await repo.git.branches.getBranch(branchName); + if (!branch) { + return undefined; + } + + // Get the merge target branch + let baseBranch; + if (mergeTargetName) { + baseBranch = await repo.git.branches.getBranch(mergeTargetName); + } + + if (!baseBranch) { + return undefined; + } + + // Get the merge base between the branch and its target + const mergeBase = await repo.git.refs.getMergeBase(branch.ref, baseBranch.ref); + if (!mergeBase) { + return undefined; + } + // Get the base commit from the merge base + const baseCommit = await repo.git.commits.getCommit(mergeBase); + if (!baseCommit) { + return undefined; + } + + // Get commits between merge base and branch head (excluding merge base) + const log = await repo.git.commits.getLog(`${baseBranch.ref}..${branch.ref}`, { limit: 0 }); + if (!log?.commits?.size) { + return undefined; + } + + // Convert Map to Array and keep in reverse chronological order (newest first, then reverse to oldest first for processing) + const commits = Array.from(log.commits.values()).reverse(); + const headCommit = commits[commits.length - 1]; + + return { + commits: commits, + baseCommit: { + sha: baseCommit.sha, + message: baseCommit.message ?? '', + }, + headCommitSha: headCommit?.sha ?? branch.sha, + }; + } catch { + return undefined; + } +} + +export function parseCoAuthorsFromGitCommit(commit: GitCommit): GitCommitIdentityShape[] { + const coAuthors: GitCommitIdentityShape[] = []; + if (!commit.message) return coAuthors; + + const coAuthorRegex = /^Co-authored-by:\s*(.+?)(?:\s*<(.+?)>)?\s*$/gm; + let match; + while ((match = coAuthorRegex.exec(commit.message)) !== null) { + const [, name, email] = match; + if (name) { + coAuthors.push({ name: name.trim(), email: email?.trim(), date: commit.date }); + } + } + + return coAuthors; +} + +/** + * Creates ComposerCommit array from existing branch commits, preserving order and mapping hunks correctly + */ +export async function createComposerCommitsFromGitCommits( + repo: Repository, + commits: GitCommit[], +): Promise<{ commits: ComposerCommit[]; hunks: ComposerHunk[] } | undefined> { + try { + const currentUser = await repo.git.config.getCurrentUser(); + const composerCommits: ComposerCommit[] = []; + const allHunks: ComposerHunk[] = []; + let count = 0; + + // Process commits in order (oldest first) + for (const commit of commits) { + // Get the diff for this commit + const diffService = repo.git.diff; + if (!diffService?.getDiff) { + continue; + } + + const diff = await diffService.getDiff(commit.sha, `${commit.sha}~1`); + if (!diff?.contents) { + continue; + } + + // Parse the diff to get hunks + const parsedDiff = parseGitDiff(diff.contents); + const commitHunkIndices: number[] = []; + const author = { + ...commit.author, + name: commit.author.name === 'You' ? (currentUser?.name ?? commit.author.name) : commit.author.name, + }; + + const { hunks, count: newCount } = convertDiffToComposerHunks( + parsedDiff, + 'commits', + count, + author, + parseCoAuthorsFromGitCommit(commit), + ); + allHunks.push(...hunks); + count = newCount; + commitHunkIndices.push(...hunks.map(h => h.index)); + + // Create ComposerCommit + const composerCommit: ComposerCommit = { + id: commit.sha, + message: commit.message || '', + sha: commit.sha, + hunkIndices: commitHunkIndices, + }; + + composerCommits.push(composerCommit); + } + + return { + commits: composerCommits, + hunks: allHunks, + }; + } catch { + return undefined; + } +} + +/** + * Calculates the combined diff from all branch commits for safety state validation + */ +export async function calculateCombinedDiffBetweenCommits( + repo: Repository, + baseCommitSha: string, + headCommitSha: string, +): Promise { + try { + const diffService = repo.git.diff; + if (!diffService?.getDiff) { + return undefined; + } + + // Get the combined diff from base to head + const diff = await diffService.getDiff(headCommitSha, baseCommitSha); + if (!diff?.contents) { + return undefined; + } + + return diff; + } catch { + return undefined; + } +} diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 9f181967c13c4..5d4d4db67ab2e 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -17,6 +17,7 @@ import type { InspectCommandArgs } from '../../../commands/inspect'; import type { OpenOnRemoteCommandArgs } from '../../../commands/openOnRemote'; import type { OpenPullRequestOnRemoteCommandArgs } from '../../../commands/openPullRequestOnRemote'; import type { CreatePatchCommandArgs } from '../../../commands/patches'; +import type { RecomposeBranchCommandArgs } from '../../../commands/recomposeBranch'; import type { Config, GraphBranchesVisibility, @@ -722,6 +723,7 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.recomposeBranch', { + repoPath: ref.repoPath, + branchName: ref.name, + source: 'graph', + }); + } + @log() private explainCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision');