Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Adds a _Reveal in Explorer View_ option for working tree (WIP) files in the _Inspect_ and _Commit Graph_ views — right-click a staged, unstaged, or conflicted working file to reveal and select it in VS Code's Explorer view ([#5387](https://github.com/gitkraken/vscode-gitlens/issues/5387))
- Adds _Focus on Branch_, _Focus on Worktree_, and _Solo Branch_ options to the _Commit Graph_ — right-click a branch or worktree in the side bar (or a graph row) to focus the graph and minimap on it, and right-click a working tree (WIP) row to focus or solo the graph on that worktree's branch ([#5388](https://github.com/gitkraken/vscode-gitlens/issues/5388))
- Adds manual take-side fallbacks and conflict-type labels to the AI **Resolve** mode in the _Commit Graph_ WIP details panel — conflicts the AI can't auto-merge (binary, symlink, submodule, file-mode, add/add, and rename/rename or rename/delete conflicts) are now labeled by type and offer inline _Take Current_, _Take Incoming_, and _Delete_ actions instead of dead-ending as "needs review"; resolving a rename/rename conflict also removes the other side's renamed file. Also decodes UTF-16/BOM-encoded files so their conflicts can be resolved rather than skipped ([#5393](https://github.com/gitkraken/vscode-gitlens/issues/5393))
- Adds a commit signing indicator to the _Commit Graph_'s working changes (WIP) commit box — a key icon appears when commits will be signed (via the repo's `commit.gpgsign` Git config or VS Code's `git.enableCommitSigning` setting), with the signing format (GPG, SSH, X.509, or OpenPGP) shown on hover

### Changed

Expand All @@ -25,6 +26,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Format string examples are rendered by the real formatter, so the preview always matches what GitLens displays
- Adds _Cloud Integrations_ and _AI_ categories — view and connect hosting and issue service integrations, and manage the AI provider and model, GitKraken MCP, default coding agent, and Claude Code hooks
- Shows connection-aware cues in the category rail — a connected/total count for _Cloud Integrations_ and a rule count for _Autolinks_
- Changes commits created from the _Commit Graph_'s working changes (WIP) commit box to honor VS Code's `git.enableCommitSigning` setting — matching the built-in Source Control commit behavior; previously only the repo's `commit.gpgsign` Git config was respected

### Fixed

Expand Down
13 changes: 9 additions & 4 deletions packages/git-cli/src/providers/__tests__/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, wr
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { FileSystemProvider, GitServiceContext, GitServiceHooks } from '@gitlens/git/context.js';
import type { FileSystemProvider, GitServiceConfig, GitServiceContext, GitServiceHooks } from '@gitlens/git/context.js';
import { Logger } from '@gitlens/utils/logger.js';
import { toFsPath } from '@gitlens/utils/uri.js';
import { CliGitProvider } from '../../../cliGitProvider.js';
Expand Down Expand Up @@ -61,10 +61,11 @@ function ensureLogger() {
});
}

function createMinimalContext(hooks?: GitServiceHooks): GitServiceContext {
function createMinimalContext(hooks?: GitServiceHooks, config?: GitServiceConfig): GitServiceContext {
return {
fs: createNodeFs(),
hooks: hooks,
config: config,
};
}

Expand Down Expand Up @@ -99,7 +100,11 @@ function createNodeFs(): FileSystemProvider {
*
* Call `cleanup()` in your `teardown()` / `suiteTeardown()`.
*/
export function createTestRepo(options?: { hooks?: GitServiceHooks; gitOptions?: GitOptions }): TestRepo {
export function createTestRepo(options?: {
hooks?: GitServiceHooks;
gitOptions?: GitOptions;
config?: GitServiceConfig;
}): TestRepo {
ensureLogger();

const dir = mkdtempSync(join(tmpdir(), 'gitlens-test-'));
Expand All @@ -123,7 +128,7 @@ export function createTestRepo(options?: { hooks?: GitServiceHooks; gitOptions?:
env: { ...process.env, GIT_COMMITTER_DATE: '2024-01-01T00:00:00Z', GIT_AUTHOR_DATE: '2024-01-01T00:00:00Z' },
});

const context = createMinimalContext(options?.hooks);
const context = createMinimalContext(options?.hooks, options?.config);
const provider = new CliGitProvider({
context: context,
locator: getGitLocation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,80 @@ suite('OperationsGitSubProvider signing', () => {
r.cleanup();
}
});

test('host signing override adds -S even when commit.gpgsign is false', async () => {
const failedCalls: Array<{ reason: SigningErrorReason; format: SigningFormat }> = [];
const signedCalls: unknown[] = [];
const r = createTestRepo({
config: { commits: {}, signing: { enabled: true } },
hooks: {
commits: {
onSigned: (...args) => signedCalls.push(args),
onSigningFailed: (reason, format) => failedCalls.push({ reason: reason, format: format }),
},
},
});
try {
// The helper leaves `commit.gpgsign=false` — without an explicit `-S` from the host
// override, git would never invoke the (broken) gpg program and the commit would
// succeed. A SigningError here is the proof that `-S` was passed.
execFileSync('git', ['config', 'gpg.format', 'openpgp'], { cwd: r.path, stdio: 'pipe' });
execFileSync('git', ['config', 'gpg.program', 'node --eval process.exit(1)'], {
cwd: r.path,
stdio: 'pipe',
});

writeFileSync(join(r.path, 'override.txt'), 'content\n');
execFileSync('git', ['add', 'override.txt'], { cwd: r.path, stdio: 'pipe' });

await assert.rejects(
() => r.provider.ops.commit(r.path, 'should attempt to sign via override'),
ex => SigningError.is(ex),
'Expected a SigningError — the override should force `-S` despite commit.gpgsign=false',
);

assert.strictEqual(failedCalls.length, 1, 'Expected onSigningFailed hook to fire exactly once');
assert.strictEqual(failedCalls[0].format, 'openpgp');
assert.strictEqual(signedCalls.length, 0, 'onSigned must not fire when signing fails');
} finally {
r.cleanup();
}
});

test('without the host override, broken signer config does not affect commits', async () => {
const calls: unknown[] = [];
const r = createTestRepo({
config: { commits: {}, signing: { enabled: false } },
hooks: {
commits: {
onSigned: (...args) => calls.push(args),
onSigningFailed: (...args) => calls.push(args),
},
},
});
try {
// Same broken gpg program as above, but no override and `commit.gpgsign=false` —
// the commit must go through without ever invoking the signer.
execFileSync('git', ['config', 'gpg.program', 'node --eval process.exit(1)'], {
cwd: r.path,
stdio: 'pipe',
});

writeFileSync(join(r.path, 'plain.txt'), 'content\n');
execFileSync('git', ['add', 'plain.txt'], { cwd: r.path, stdio: 'pipe' });

await r.provider.ops.commit(r.path, 'unsigned commit');

const log = execFileSync('git', ['log', '-1', '--format=%s'], {
cwd: r.path,
encoding: 'utf-8',
}).trim();
assert.strictEqual(log, 'unsigned commit');
assert.strictEqual(calls.length, 0, 'No signing hooks should fire for an unsigned commit');
} finally {
r.cleanup();
}
});
});

suite('OperationsGitSubProvider.push', () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/git-cli/src/providers/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
if (options?.date) {
params.push(`--date=${options.date}`);
}
// Host-level override (e.g. VS Code's `git.enableCommitSigning`) — request signing explicitly
// via `-S`. Implicit repo-config signing (`commit.gpgsign=true`) needs no flag, and `-S`
// alongside it is harmless, so the override can only enable signing, never force it off.
const sign = this.context.config?.signing?.enabled === true;
if (sign) {
params.push('-S');
}
// Read commit message from stdin via -F - to avoid shell escaping issues
params.push('-F', '-');

Expand All @@ -193,6 +200,15 @@ export class OperationsGitSubProvider implements GitOperationsSubProvider {
);
this.context.hooks?.cache?.onReset?.(repoPath, 'branches', 'status');
this.context.hooks?.repository?.onChanged?.(repoPath, ['head', 'heads', 'index']);
if (sign) {
// `-S` was passed and the commit succeeded, so signing is confirmed — fire the
// explicit-sign hook (same contract as the patch provider's `commit-tree -S` path).
let format: SigningFormat | undefined;
try {
format = (await this.provider.config.getSigningConfig?.(repoPath))?.format;
} catch {}
this.context.hooks?.commits?.onSigned?.(format ?? 'gpg', options?.source);
}
} catch (ex) {
scope?.error(ex);
await this.throwIfSigningError(repoPath, params, ex, options?.source);
Expand Down
7 changes: 4 additions & 3 deletions packages/git/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,10 @@ export interface GitServiceHooks {
/**
* Called when a commit is signed successfully.
*
* Note: currently fired only from explicit-sign paths in the patch provider
* (where signing is requested via `-S` and confirmed up front). Operations
* that sign implicitly via `commit.gpgsign=true` (`commit`, `merge`, `pull`,
* Note: currently fired only from explicit-sign paths — the patch provider's
* `commit-tree -S` and the commit operation when the host signing override
Comment thread
ianhattendorf marked this conversation as resolved.
* (`config.signing.enabled`) adds `-S`. Operations that sign implicitly via
* `commit.gpgsign=true` (`commit` without the override, `merge`, `pull`,
* `rebase`, `revert`, `cherryPick`) do not fire this hook because the
* library cannot cheaply confirm that signing actually occurred without an
* extra `git config`/`log --show-signature` call.
Expand Down
16 changes: 16 additions & 0 deletions src/webviews/apps/plus/graph/components/gl-commit-box.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ export const commitBoxStyles = css`
justify-content: space-between;
}

.options-group {
display: flex;
align-items: center;
gap: 0.4rem;
}

.signing-indicator {
display: inline-flex;
color: var(--vscode-descriptionForeground);
}

.signing-indicator:focus-visible {
outline: 0.1rem solid var(--vscode-focusBorder);
outline-offset: 0.2rem;
}

.compose-icon {
color: var(--gl-agent-working-color);
}
Expand Down
45 changes: 39 additions & 6 deletions src/webviews/apps/plus/graph/components/gl-commit-box.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { html, LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { isMac } from '@env/platform.js';
import type { WipSigning } from '../../../../plus/graph/detailsProtocol.js';
import { elementBase, scrollableBase } from '../../../shared/components/styles/lit/base.css.js';
import { commitBoxStyles } from './gl-commit-box.css.js';
import '../../../shared/components/button.js';
Expand Down Expand Up @@ -55,21 +56,40 @@ export class GlCommitBox extends LitElement {
@property()
commitError?: string;

@property({ type: Object })
signing?: WipSigning;

override render() {
return html`
<div class="options">
${this.renderAmendToggle()}
${this.aiEnabled
? html`<gl-button appearance="secondary" @click=${this.onCompose}>
<code-icon class="compose-icon" icon="wand" slot="prefix"></code-icon>
Compose
</gl-button>`
: nothing}
<div class="options-group">
${this.renderSigningIndicator()}
${this.aiEnabled
? html`<gl-button appearance="secondary" @click=${this.onCompose}>
<code-icon class="compose-icon" icon="wand" slot="prefix"></code-icon>
Compose
</gl-button>`
: nothing}
</div>
</div>
${this.renderTextarea()} ${this.renderActionBar()}
`;
}

private renderSigningIndicator() {
if (!this.signing?.enabled) return nothing;

const label = `Commits will be signed using ${getSigningFormatLabel(this.signing.format)}`;
return html`
<gl-tooltip content=${label} placement="bottom">
<span class="signing-indicator" tabindex="0" role="img" aria-label=${label}>
<code-icon icon="key"></code-icon>
</span>
</gl-tooltip>
`;
}

private renderAmendToggle() {
return html`
<gl-checkbox
Expand Down Expand Up @@ -227,6 +247,19 @@ export class GlCommitBox extends LitElement {
}
}

function getSigningFormatLabel(format: WipSigning['format']): string {
switch (format) {
case 'ssh':
return 'SSH';
case 'x509':
return 'X.509';
case 'openpgp':
return 'OpenPGP';
default:
return 'GPG';
}
}

declare global {
interface HTMLElementTagNameMap {
'gl-commit-box': GlCommitBox;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,7 @@ export class GlGraphDetailsPanel extends SignalWatcher(LitElement) {
.disabledReason=${this._actions.canCommitReason()}
.aiEnabled=${this._state.preferences.get()?.aiEnabled ?? false}
.commitError=${this._state.commitError.get()}
.signing=${wip.signing}
@message-change=${this.handleCommitMessageChange}
@amend-change=${this.handleAmendChange}
@commit=${this.handleCommit}
Expand Down
14 changes: 14 additions & 0 deletions src/webviews/commitDetails/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { GitPausedOperationStatus } from '@gitlens/git/models/pausedOperati
import type { PullRequestShape } from '@gitlens/git/models/pullRequest.js';
import type { GitBranchReference } from '@gitlens/git/models/reference.js';
import type { GitCommitSearchContext } from '@gitlens/git/models/search.js';
import type { SigningFormat } from '@gitlens/git/models/signature.js';
import type { CurrentUserNameStyle } from '@gitlens/git/utils/commit.utils.js';
import type { DateTimeFormat } from '@gitlens/utils/date.js';
import type { Config, DateStyle } from '../../config.js';
Expand Down Expand Up @@ -112,6 +113,13 @@ export interface WipStats {
context?: string;
}

/** Repo-level commit-signing status for the WIP commit box — see {@link Wip.signing}. */
export interface WipSigning {
/** Whether commits will be signed (repo `commit.gpgsign` or the host's `git.enableCommitSigning` override). */
enabled: boolean;
format: SigningFormat;
}

export interface Wip {
changes: WipChange | undefined;
repositoryCount: number;
Expand All @@ -133,6 +141,12 @@ export interface Wip {
* rely on it in practice (guard with `?.` for the shared-type contract).
*/
stats?: WipStats;
/**
* Commit-signing status for this wip's repo — drives the "will be signed" indicator in the
* Graph's commit box. Optional for the same reason as {@link Wip.stats}: only the Graph's
* `getWipForRepoAndStats` populates it.
*/
signing?: WipSigning;
}

export interface DraftState {
Expand Down
1 change: 1 addition & 0 deletions src/webviews/plus/graph/detailsProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type {
Preferences,
State,
Wip,
WipSigning,
WipStats,
WorkingFileSorting,
} from '../../commitDetails/protocol.js';
Expand Down
Loading
Loading