Skip to content

Commit b85bba6

Browse files
Adds initial support for recomposing commits from a branch (#4713)
* Adds initial support for recomposing commits from a branch * Adds authorship to recomposed commits * Fixes failure to finish and commit when no workdir changes present * Fixes state provider after rebase * Recursively checks for merge target until one with commits found
1 parent a6e3c6f commit b85bba6

File tree

21 files changed

+1059
-163
lines changed

21 files changed

+1059
-163
lines changed

contributions.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4404,6 +4404,36 @@
44044404
]
44054405
}
44064406
},
4407+
"gitlens.recomposeBranch": {
4408+
"label": "Recompose Commits (Preview)...",
4409+
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
4410+
},
4411+
"gitlens.recomposeBranch:graph": {
4412+
"label": "Recompose Commits (Preview)",
4413+
"icon": "$(sparkle)",
4414+
"menus": {
4415+
"webview/context": [
4416+
{
4417+
"when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
4418+
"group": "1_gitlens_ai",
4419+
"order": 15
4420+
}
4421+
]
4422+
}
4423+
},
4424+
"gitlens.recomposeBranch:views": {
4425+
"label": "Recompose Commits (Preview)",
4426+
"icon": "$(sparkle)",
4427+
"menus": {
4428+
"view/item/context": [
4429+
{
4430+
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
4431+
"group": "1_gitlens_ai",
4432+
"order": 15
4433+
}
4434+
]
4435+
}
4436+
},
44074437
"gitlens.regenerateMarkdownDocument": {
44084438
"label": "Regenerate",
44094439
"icon": "$(refresh)",

package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7924,6 +7924,21 @@
79247924
"command": "gitlens.quickOpenFileHistory:graphDetails",
79257925
"title": "Quick Open File History"
79267926
},
7927+
{
7928+
"command": "gitlens.recomposeBranch",
7929+
"title": "Recompose Commits (Preview)...",
7930+
"category": "GitLens"
7931+
},
7932+
{
7933+
"command": "gitlens.recomposeBranch:graph",
7934+
"title": "Recompose Commits (Preview)",
7935+
"icon": "$(sparkle)"
7936+
},
7937+
{
7938+
"command": "gitlens.recomposeBranch:views",
7939+
"title": "Recompose Commits (Preview)",
7940+
"icon": "$(sparkle)"
7941+
},
79277942
{
79287943
"command": "gitlens.regenerateMarkdownDocument",
79297944
"title": "Regenerate",
@@ -12693,6 +12708,18 @@
1269312708
"command": "gitlens.quickOpenFileHistory:graphDetails",
1269412709
"when": "false"
1269512710
},
12711+
{
12712+
"command": "gitlens.recomposeBranch",
12713+
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
12714+
},
12715+
{
12716+
"command": "gitlens.recomposeBranch:graph",
12717+
"when": "false"
12718+
},
12719+
{
12720+
"command": "gitlens.recomposeBranch:views",
12721+
"when": "false"
12722+
},
1269612723
{
1269712724
"command": "gitlens.regenerateMarkdownDocument",
1269812725
"when": "false"
@@ -18313,6 +18340,11 @@
1831318340
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
1831418341
"group": "1_gitlens_ai@20"
1831518342
},
18343+
{
18344+
"command": "gitlens.recomposeBranch:views",
18345+
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
18346+
"group": "1_gitlens_ai@15"
18347+
},
1831618348
{
1831718349
"command": "gitlens.views.openBranchOnRemote",
1831818350
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && !listMultiSelection",
@@ -24190,6 +24222,11 @@
2419024222
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
2419124223
"group": "1_gitlens_ai@20"
2419224224
},
24225+
{
24226+
"command": "gitlens.recomposeBranch:graph",
24227+
"when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
24228+
"group": "1_gitlens_ai@15"
24229+
},
2419324230
{
2419424231
"command": "gitlens.graph.openBranchOnRemote",
2419524232
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && gitlens:repos:withRemotes",

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import './commands/openWorkingFile';
5454
import './commands/patches';
5555
import './commands/quickWizard';
5656
import './commands/rebaseEditor';
57+
import './commands/recomposeBranch';
5758
import './commands/refreshHover';
5859
import './commands/regenerateMarkdownDocument';
5960
import './commands/remoteProviders';

src/commands/recomposeBranch.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { window } from 'vscode';
2+
import type { Sources } from '../constants.telemetry';
3+
import type { Container } from '../container';
4+
import { CommandQuickPickItem } from '../quickpicks/items/common';
5+
import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker';
6+
import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker';
7+
import { command, executeCommand } from '../system/-webview/command';
8+
import { getNodeRepoPath } from '../views/nodes/abstract/viewNode';
9+
import type { WebviewPanelShowCommandArgs } from '../webviews/webviewsController';
10+
import { GlCommandBase } from './commandBase';
11+
import type { CommandContext } from './commandContext';
12+
import { isCommandContextViewNodeHasBranch } from './commandContext.utils';
13+
14+
export interface RecomposeBranchCommandArgs {
15+
repoPath?: string;
16+
branchName?: string;
17+
source?: Sources;
18+
}
19+
20+
@command()
21+
export class RecomposeBranchCommand extends GlCommandBase {
22+
constructor(private readonly container: Container) {
23+
super(['gitlens.recomposeBranch', 'gitlens.recomposeBranch:views']);
24+
}
25+
26+
protected override preExecute(context: CommandContext, args?: RecomposeBranchCommandArgs): Promise<void> {
27+
if (isCommandContextViewNodeHasBranch(context)) {
28+
args = { ...args };
29+
args.repoPath = args.repoPath ?? getNodeRepoPath(context.node);
30+
args.branchName = args.branchName ?? context.node.branch.name;
31+
args.source = args.source ?? 'view';
32+
}
33+
34+
return this.execute(args);
35+
}
36+
37+
async execute(args?: RecomposeBranchCommandArgs): Promise<void> {
38+
try {
39+
// Get repository path using picker fallback
40+
const repoPath =
41+
args?.repoPath ??
42+
(await getBestRepositoryOrShowPicker(this.container, undefined, undefined, 'Recompose Branch'))?.path;
43+
if (!repoPath) return;
44+
45+
args = { ...args };
46+
47+
// Get branch name using picker fallback
48+
let branchName = args.branchName;
49+
if (!branchName) {
50+
const pick = await showReferencePicker(repoPath, 'Recompose Branch', 'Choose a branch to recompose', {
51+
include: ReferencesQuickPickIncludes.Branches,
52+
sort: { branches: { current: true } },
53+
});
54+
if (pick == null || pick instanceof CommandQuickPickItem) return;
55+
56+
if (pick.refType === 'branch') {
57+
branchName = pick.name;
58+
} else {
59+
return;
60+
}
61+
}
62+
63+
// Validate that the repository exists
64+
const repo = this.container.git.getRepository(repoPath);
65+
if (!repo) {
66+
void window.showErrorMessage('Repository not found');
67+
return;
68+
}
69+
70+
// Validate that the branch exists
71+
const branch = await repo.git.branches.getBranch(branchName);
72+
if (!branch) {
73+
void window.showErrorMessage(`Branch '${branchName}' not found`);
74+
return;
75+
}
76+
77+
// Check if branch is remote-only
78+
if (branch.remote && !branch.upstream) {
79+
void window.showErrorMessage(`Cannot recompose remote-only branch '${branchName}'`);
80+
return;
81+
}
82+
83+
// Open the composer with branch mode
84+
await executeCommand<WebviewPanelShowCommandArgs>('gitlens.showComposerPage', undefined, {
85+
repoPath: repoPath,
86+
source: args?.source,
87+
mode: 'preview',
88+
branchName: branchName,
89+
});
90+
} catch (ex) {
91+
void window.showErrorMessage(`Failed to recompose branch: ${ex}`);
92+
}
93+
}
94+
}

src/constants.commands.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ export type ContributedCommands =
248248
| 'gitlens.quickOpenFileHistory'
249249
| 'gitlens.quickOpenFileHistory:commitDetails'
250250
| 'gitlens.quickOpenFileHistory:graphDetails'
251+
| 'gitlens.recomposeBranch:graph'
252+
| 'gitlens.recomposeBranch:views'
251253
| 'gitlens.regenerateMarkdownDocument'
252254
| 'gitlens.restore.file:commitDetails'
253255
| 'gitlens.restore.file:graphDetails'
@@ -920,6 +922,7 @@ export type ContributedPaletteCommands =
920922
| 'gitlens.pullRepositories'
921923
| 'gitlens.pushRepositories'
922924
| 'gitlens.quickOpenFileHistory'
925+
| 'gitlens.recomposeBranch'
923926
| 'gitlens.reset'
924927
| 'gitlens.resetViewsLayout'
925928
| 'gitlens.revealCommitInView'

src/env/node/git/sub-providers/patch.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
WorktreeCreateError,
1111
} from '../../../../git/errors';
1212
import type { GitPatchSubProvider } from '../../../../git/gitProvider';
13-
import type { GitCommit } from '../../../../git/models/commit';
13+
import type { GitCommit, GitCommitIdentityShape } from '../../../../git/models/commit';
1414
import { log } from '../../../../system/decorators/log';
1515
import { Logger } from '../../../../system/logger';
1616
import { getLogScope } from '../../../../system/logger.scope';
@@ -178,16 +178,16 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
178178
async createUnreachableCommitsFromPatches(
179179
repoPath: string,
180180
base: string | undefined,
181-
patches: { message: string; patch: string }[],
181+
patches: { message: string; patch: string; author?: GitCommitIdentityShape }[],
182182
): Promise<string[]> {
183183
// Create a temporary index file
184184
await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, base);
185185
const { env } = disposableIndex;
186186

187187
const shas: string[] = [];
188188

189-
for (const { message, patch } of patches) {
190-
const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch);
189+
for (const { message, patch, author } of patches) {
190+
const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch, author);
191191
shas.push(sha);
192192
base = sha;
193193
}
@@ -201,6 +201,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
201201
base: string | undefined,
202202
message: string,
203203
patch: string,
204+
author?: GitCommitIdentityShape,
204205
): Promise<string> {
205206
const scope = getLogScope();
206207

@@ -221,9 +222,18 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
221222
let result = await this.git.exec({ cwd: repoPath, env: env }, 'write-tree');
222223
const tree = result.stdout.trim();
223224

225+
// Set the author if provided
226+
const commitEnv = author
227+
? {
228+
...env,
229+
GIT_AUTHOR_NAME: author.name,
230+
GIT_AUTHOR_EMAIL: author.email || '',
231+
}
232+
: env;
233+
224234
// Create new commit from the tree
225235
result = await this.git.exec(
226-
{ cwd: repoPath, env: env },
236+
{ cwd: repoPath, env: commitEnv },
227237
'commit-tree',
228238
tree,
229239
...(base ? ['-p', base] : []),

src/env/node/git/sub-providers/refs.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,21 @@ export class RefsGitSubProvider implements GitRefsSubProvider {
178178
);
179179
return result.stdout.trim() || undefined;
180180
}
181+
182+
@log()
183+
async updateReference(
184+
repoPath: string,
185+
ref: string,
186+
newRef: string,
187+
cancellation?: CancellationToken,
188+
): Promise<void> {
189+
const scope = getLogScope();
190+
191+
try {
192+
await this.git.exec({ cwd: repoPath, cancellation: cancellation }, 'update-ref', ref, newRef);
193+
} catch (ex) {
194+
Logger.error(ex, scope);
195+
if (isCancellationError(ex)) throw ex;
196+
}
197+
}
181198
}

src/env/node/git/sub-providers/status.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class StatusGitSubProvider implements GitStatusSubProvider {
123123
@log()
124124
async hasWorkingChanges(
125125
repoPath: string,
126-
options?: { staged?: boolean; unstaged?: boolean; untracked?: boolean },
126+
options?: { staged?: boolean; unstaged?: boolean; untracked?: boolean; throwOnError?: boolean },
127127
cancellation?: CancellationToken,
128128
): Promise<boolean> {
129129
const scope = getLogScope();
@@ -169,6 +169,7 @@ export class StatusGitSubProvider implements GitStatusSubProvider {
169169
// Log other errors and return false for graceful degradation
170170
Logger.error(ex, scope);
171171
setLogScopeExit(scope, ' \u2022 error checking for changes');
172+
if (options?.throwOnError) throw ex;
172173
return false;
173174
}
174175
}

src/git/gitProvider.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { GitUri } from './gitUri';
1111
import type { GitConflictFile } from './models';
1212
import type { GitBlame, GitBlameLine } from './models/blame';
1313
import type { GitBranch } from './models/branch';
14-
import type { GitCommit, GitCommitStats, GitStashCommit } from './models/commit';
14+
import type { GitCommit, GitCommitIdentityShape, GitCommitStats, GitStashCommit } from './models/commit';
1515
import type { GitContributor, GitContributorsStats } from './models/contributor';
1616
import type {
1717
GitDiff,
@@ -554,7 +554,7 @@ export interface GitPatchSubProvider {
554554
createUnreachableCommitsFromPatches(
555555
repoPath: string,
556556
base: string | undefined,
557-
patches: { message: string; patch: string }[],
557+
patches: { message: string; patch: string; author?: GitCommitIdentityShape }[],
558558
): Promise<string[]>;
559559
createEmptyInitialCommit(repoPath: string): Promise<string>;
560560

@@ -591,6 +591,7 @@ export interface GitRefsSubProvider {
591591
pathOrUri?: string | Uri,
592592
cancellation?: CancellationToken,
593593
): Promise<boolean>;
594+
updateReference(repoPath: string, ref: string, newRef: string, cancellation?: CancellationToken): Promise<void>;
594595
}
595596

596597
export interface GitRemotesSubProvider {
@@ -761,6 +762,8 @@ export interface GitStatusSubProvider {
761762
unstaged?: boolean;
762763
/** Check for untracked files (default: true) */
763764
untracked?: boolean;
765+
/** Throw errors rather than returning false */
766+
throwOnError?: boolean;
764767
},
765768
cancellation?: CancellationToken,
766769
): Promise<boolean>;

src/plus/integrations/providers/github/sub-providers/refs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,14 @@ export class RefsGitSubProvider implements GitRefsSubProvider {
133133
): Promise<boolean> {
134134
return Promise.resolve(true);
135135
}
136+
137+
@log()
138+
updateReference(
139+
_repoPath: string,
140+
_ref: string,
141+
_newRef: string,
142+
_cancellation?: CancellationToken,
143+
): Promise<void> {
144+
return Promise.resolve();
145+
}
136146
}

0 commit comments

Comments
 (0)