diff --git a/contributions.json b/contributions.json index e86458bebc5a4..ec6ef984c54b4 100644 --- a/contributions.json +++ b/contributions.json @@ -2251,6 +2251,11 @@ ] } }, + "gitlens.git.setupCommitSigning": { + "label": "Setup Commit Signing...", + "icon": "$(key)", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, "gitlens.gitCommands": { "label": "Git Command Palette", "commandPalette": "!gitlens:disabled", diff --git a/docs/COMMIT_SIGNING_PLAN.md b/docs/COMMIT_SIGNING_PLAN.md new file mode 100644 index 0000000000000..ce64e69e54915 --- /dev/null +++ b/docs/COMMIT_SIGNING_PLAN.md @@ -0,0 +1,789 @@ +# GitLens Commit Signing - Implementation Plan + +## Status: Phase 1 Complete ✅ + +**Phase 1 (Core Infrastructure)** has been fully implemented. All core signing functionality is in place. + +### Phase 1 Implementation Summary + +| Component | Location | +| ---------------------------------------------- | ----------------------------------------------- | +| `gitConfigsLogWithSignatures` constant | `src/env/node/git/git.ts` | +| `ConfigGitSubProvider.getSigningConfig()` | `src/env/node/git/sub-providers/config.ts` | +| `ConfigGitSubProvider.validateSigningSetup()` | `src/env/node/git/sub-providers/config.ts` | +| `ConfigGitSubProvider.setSigningConfig()` | `src/env/node/git/sub-providers/config.ts` | +| `ConfigGitSubProvider.getSigningConfigFlags()` | `src/env/node/git/sub-providers/config.ts` | +| `CommitsGitSubProvider.getCommitSignature()` | `src/env/node/git/sub-providers/commits.ts` | +| `CommitsGitSubProvider.parseSignature()` | `src/env/node/git/sub-providers/commits.ts` | +| `GitCommit._signature` + `getSignature()` | `src/git/models/commit.ts` | +| `SigningConfig`, `CommitSignature` types | `src/git/models/signature.ts` | +| `SigningError` + `SigningErrorReason` | `src/git/errors.ts` | +| Patch sub-provider signing support | `src/env/node/git/sub-providers/patch.ts` | +| Composer integration | `src/webviews/plus/composer/composerWebview.ts` | +| Telemetry events | `src/constants.telemetry.ts` | +| `GitProvider` interface updates | `src/git/gitProvider.ts` | + +### Remaining Work + +- **Phase 2**: Setup Wizard & Configuration UI +- **Phase 3**: Signature Verification & Display (badges in Commit Graph, etc.) + +## Philosophy: Leverage Git Config, Extend Existing Sub-Providers + +This plan takes a **Git-first approach** - we read directly from Git configuration and extend existing sub-providers rather than creating new ones. + +### Key Principles + +1. **Read from Git Config** - All signing configuration comes from Git's native config system +2. **No Duplication** - Don't create GitLens settings that mirror Git config values +3. **Extend Existing Sub-Providers** - Add signing to `config` and `commits` sub-providers +4. **Minimal Settings** - Only add GitLens settings for UI/UX features Git doesn't support + +--- + +## Architecture Decision: Extend Existing Sub-Providers + +**Rather than creating a new `signing` sub-provider, we extend existing ones:** + +### A. Config Sub-Provider (`src/env/node/git/sub-providers/config.ts`) + +- Handles signing **configuration** (similar to how it handles `user.name`, `user.email`) +- Read/write signing settings from Git config +- Validate signing setup +- Platform-specific GPG/SSH detection + +### B. Commits Sub-Provider (`src/env/node/git/sub-providers/commits.ts`) + +- Handles signature **parsing and display** (commit metadata) +- Parse `git log --show-signature` output +- Extend `GitCommit` model with signature property + +### C. Patch Sub-Provider (`src/env/node/git/sub-providers/patch.ts`) + +- Add signing support to unreachable commit creation +- Respect auto-sign configuration + +### Rationale + +✅ **No new sub-provider needed** - Simpler architecture +✅ **Natural fit** - Config goes with config, commit data goes with commits +✅ **Smaller surface area** - Fewer files to modify +✅ **Consistent with existing patterns** - `config` already handles `user.name`, `user.email` +✅ **Easier to maintain** - Related functionality stays together + +--- + +## Configuration Strategy + +### What We Read from Git Config (No GitLens Settings Needed) + +- `commit.gpgsign` - Whether to auto-sign commits (boolean) +- `gpg.format` - Signing format: `gpg`, `ssh`, `x509`, `openpgp` +- `gpg.program` - Path to GPG program +- `gpg.ssh.program` - Path to SSH program (for SSH signing) +- `user.signingkey` - The signing key ID or path +- `gpg.ssh.allowedSignersFile` - SSH allowed signers file path + +### Minimal GitLens Settings (Only What Git Can't Provide) + +```typescript +interface CommitSigningConfig { + readonly showSetupWizard: boolean; // default: true + readonly showStatusBar: boolean; // default: true + readonly showSignatureBadges: boolean; // default: true + readonly enableKeyGeneration: boolean; // default: true +} +``` + +--- + +## Implementation Phases + +### Phase 1: Core Infrastructure ✅ COMPLETE + +#### 1.1 Extend ConfigGitSubProvider + +```typescript +// In src/env/node/git/sub-providers/config.ts + +export class ConfigGitSubProvider implements GitConfigSubProvider { + // NEW: Read signing configuration from Git config + async getSigningConfig(repoPath: string): Promise { + const autoSign = await this.git.config__get('commit.gpgsign', repoPath); + const format = (await this.git.config__get('gpg.format', repoPath)) ?? 'gpg'; + const signingKey = await this.git.config__get('user.signingkey', repoPath); + const gpgProgram = await this.git.config__get('gpg.program', repoPath); + const sshProgram = await this.git.config__get('gpg.ssh.program', repoPath); + const allowedSignersFile = await this.git.config__get('gpg.ssh.allowedSignersFile', repoPath); + + return { + enabled: autoSign === 'true', + format: format as 'gpg' | 'ssh' | 'x509' | 'openpgp', + signingKey, + gpgProgram, + sshProgram, + allowedSignersFile, + }; + } + + // NEW: Validate signing setup + async validateSigningSetup(repoPath: string): Promise { + const config = await this.getSigningConfig(repoPath); + + if (!config.signingKey) { + return { valid: false, error: 'No signing key configured' }; + } + + if (config.format === 'gpg') { + const gpgPath = config.gpgProgram ?? (await this.findGPG()); + if (!gpgPath) { + return { valid: false, error: 'GPG not found' }; + } + } else if (config.format === 'ssh') { + const sshPath = config.sshProgram ?? (await this.findSSH()); + if (!sshPath) { + return { valid: false, error: 'SSH not found' }; + } + } + + return { valid: true }; + } + + // NEW: Set signing configuration (for setup wizard) + async setSigningConfig(repoPath: string, config: Partial): Promise { + if (config.enabled !== undefined) { + await this.setConfig(repoPath, 'commit.gpgsign', config.enabled ? 'true' : 'false'); + } + if (config.format !== undefined) { + await this.setConfig(repoPath, 'gpg.format', config.format); + } + if (config.signingKey !== undefined) { + await this.setConfig(repoPath, 'user.signingkey', config.signingKey); + } + if (config.gpgProgram !== undefined) { + await this.setConfig(repoPath, 'gpg.program', config.gpgProgram); + } + if (config.sshProgram !== undefined) { + await this.setConfig(repoPath, 'gpg.ssh.program', config.sshProgram); + } + if (config.allowedSignersFile !== undefined) { + await this.setConfig(repoPath, 'gpg.ssh.allowedSignersFile', config.allowedSignersFile); + } + } + + // NEW: Generate -c flags for git commands when signing config needs to be passed + getSigningConfigFlags(config: SigningConfig): string[] { + const flags: string[] = []; + + if (config.gpgProgram) { + flags.push('-c', `gpg.program=${config.gpgProgram}`); + } + if (config.format && config.format !== 'gpg') { + flags.push('-c', `gpg.format=${config.format}`); + } + if (config.sshProgram) { + flags.push('-c', `gpg.ssh.program=${config.sshProgram}`); + } + if (config.allowedSignersFile) { + flags.push('-c', `gpg.ssh.allowedSignersFile=${config.allowedSignersFile}`); + } + + return flags; + } +} +``` + +#### 1.2 Add Git Config for Signature Display + +**CRITICAL**: GitLens currently disables signature display with `-c log.showSignature=false` in `gitConfigsLog`. We need a new config constant for when we want signatures. + +```typescript +// In src/env/node/git/git.ts + +// EXISTING (line 64-65): +export const gitConfigsLog = ['-c', 'log.showSignature=false'] as const; + +// NEW: Config for when we WANT signatures +export const gitConfigsLogWithSignatures = ['-c', 'log.showSignature=true'] as const; +``` + +#### 1.3 Extend CommitsGitSubProvider + +```typescript +// In src/env/node/git/sub-providers/commits.ts + +export class CommitsGitSubProvider implements GitCommitsSubProvider { + // NEW: Get commit signature information + async getCommitSignature(repoPath: string, sha: string): Promise { + // Use gitConfigsLogWithSignatures to enable signature display + const result = await this.git.exec( + { cwd: repoPath, configs: gitConfigsLogWithSignatures }, + 'log', + '--show-signature', + '--format=%H', + '-1', + sha, + ); + + return this.parseSignature(result.stdout); + } + + // NEW: Parse signature from git log --show-signature output + private parseSignature(output: string): CommitSignature | undefined { + // Parse GPG/SSH signature output + // Example GPG output: + // gpg: Signature made ... + // gpg: Good signature from "Name " + // Example SSH output: + // Good "git" signature for user@example.com with ED25519 key SHA256:... + + const lines = output.split('\n'); + let status: 'good' | 'bad' | 'unknown' | 'expired' | 'revoked' = 'unknown'; + let signer: string | undefined; + let key: string | undefined; + + for (const line of lines) { + if (line.includes('Good signature') || line.includes('Good "git" signature')) { + status = 'good'; + // Extract signer info + } else if (line.includes('BAD signature')) { + status = 'bad'; + } else if (line.includes('expired')) { + status = 'expired'; + } else if (line.includes('revoked')) { + status = 'revoked'; + } + } + + if (status === 'unknown') return undefined; + + return { status, signer, key }; + } +} +``` + +#### 1.4 Update GitCommit Model + +```typescript +// In src/git/models/commit.ts + +export class GitCommit { + private _signature: CommitSignature | undefined | null; + + constructor( + // ... existing parameters ... + signature?: CommitSignature, // NEW: Optional signature passed during construction + ) { + this._signature = signature; + } + + // NEW: Lazy-load signature on demand + async getSignature(): Promise { + if (this._signature === null) return undefined; + if (this._signature !== undefined) return this._signature; + + // Fetch signature from git + this._signature = + (await this.container.git.getRepositoryService(this.repoPath).commits.getCommitSignature?.(this.sha)) ?? null; + + return this._signature ?? undefined; + } +} +``` + +**Note**: Signatures are loaded lazily to avoid performance impact on normal commit operations. Only fetch when explicitly needed (e.g., when displaying commit details). + +#### 1.5 Update Patch Sub-Provider + +```typescript +// In src/env/node/git/sub-providers/patch.ts + +private async createUnreachableCommitForPatchCore( + env: Record, + repoPath: string, + base: string | undefined, + message: string, + patch: string, + options?: { sign?: boolean } // NEW +): Promise { + const scope = getLogScope(); + + if (!patch.endsWith('\n')) { + patch += '\n'; + } + + try { + // Apply the patch to our temp index, without touching the working directory + await this.git.exec( + { cwd: repoPath, configs: gitConfigsLog, env: env, stdin: patch }, + 'apply', + '--cached', + '-', + ); + + // Create a new tree from our patched index + let result = await this.git.exec({ cwd: repoPath, env: env }, 'write-tree'); + const tree = result.stdout.trim(); + + // NEW: Check if we should sign + const signingConfig = await this.provider.config.getSigningConfig(repoPath); + const shouldSign = options?.sign ?? signingConfig.enabled; + + // Create new commit from the tree + const args = ['commit-tree', tree]; + + if (base) { + args.push('-p', base); + } + + // NEW: Add signing flag if enabled + if (shouldSign) { + args.push('-S'); + } + + args.push('-m', message); + + result = await this.git.exec({ cwd: repoPath, env: env }, ...args); + const sha = result.stdout.trim(); + + return sha; + } catch (ex) { + Logger.error(ex, scope); + debugger; + + throw ex; + } +} + +// Update both public methods to accept sign option +async createUnreachableCommitForPatch( + repoPath: string, + base: string, + message: string, + patch: string, + options?: { sign?: boolean } // NEW +): Promise { + // ... existing implementation, pass options to createUnreachableCommitForPatchCore +} + +async createUnreachableCommitsFromPatches( + repoPath: string, + base: string | undefined, + patches: { message: string; patch: string }[], + options?: { sign?: boolean } // NEW +): Promise { + // ... existing implementation, pass options to createUnreachableCommitForPatchCore +} +``` + +#### 1.5 Composer Integration + +```typescript +// In src/webviews/plus/composer/composerWebview.ts + +private async onFinishAndCommit(params: FinishAndCommitParams) { + const signingConfig = await repo.git.config.getSigningConfig(); + const shouldSign = signingConfig.enabled; + + const shas = await repo.git.patch?.createUnreachableCommitsFromPatches( + params.baseCommit?.sha, + diffInfo, + { sign: shouldSign } + ); +} +``` + +#### 1.6 Type Definitions + +```typescript +// NEW: src/git/models/signature.ts + +export interface SigningConfig { + enabled: boolean; + format: 'gpg' | 'ssh' | 'x509' | 'openpgp'; + signingKey?: string; + gpgProgram?: string; + sshProgram?: string; + allowedSignersFile?: string; +} + +export interface CommitSignature { + status: 'good' | 'bad' | 'expired' | 'revoked' | 'unknown' | 'error'; // Note: 'good'/'bad' match GPG/SSH terminology + signer?: string; + keyId?: string; + fingerprint?: string; + timestamp?: Date; + errorMessage?: string; + trustLevel?: 'ultimate' | 'full' | 'marginal' | 'never' | 'unknown'; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} +``` + +#### 1.7 Update GitProvider Interfaces + +```typescript +// In src/git/gitProvider.ts + +export interface GitConfigSubProvider { + // ... existing methods ... + + // NEW: Signing configuration methods + getSigningConfig?(repoPath: string): Promise; + validateSigningSetup?(repoPath: string): Promise; + setSigningConfig?(repoPath: string, config: Partial): Promise; +} + +export interface GitCommitsSubProvider { + // ... existing methods ... + + // NEW: Signature parsing + getCommitSignature?(repoPath: string, sha: string): Promise; +} +``` + +#### 1.6 Error Handling + +```typescript +// In src/git/models/errors.ts (or create new file) + +export class SigningError extends Error { + constructor( + public readonly reason: 'no-key' | 'gpg-not-found' | 'ssh-not-found' | 'passphrase-failed' | 'unknown', + message: string, + public readonly details?: string, + ) { + super(message); + this.name = 'SigningError'; + } +} + +// Usage in patch sub-provider: +try { + result = await this.git.exec({ cwd: repoPath, env: env }, ...args); +} catch (ex) { + if (ex instanceof Error && ex.message.includes('gpg failed to sign')) { + throw new SigningError('passphrase-failed', 'GPG failed to sign the commit', ex.message); + } else if (ex instanceof Error && ex.message.includes('no signing key')) { + throw new SigningError('no-key', 'No signing key configured', ex.message); + } + throw ex; +} +``` + +#### 1.7 Telemetry Events + +Uses the standard `Source` type for tracking where actions originate: + +```typescript +// Track signing usage and failures + +// In patch sub-provider after successful signing: +this.container.telemetry.sendEvent('commit/signed', { format: signingConfig.format }, options?.source); + +// On signing failure: +this.container.telemetry.sendEvent( + 'commit/signing/failed', + { reason: error.reason, format: signingConfig.format }, + options?.source, +); + +// Caller passes Source object: +await repo.git.patch?.createUnreachableCommitsFromPatches(base, patches, { + sign: shouldSign, + source: { source: 'composer' }, // Uses standard Source type +}); + +// In setup wizard after completion: +this.container.telemetry.sendEvent('commit/signing/setup', { + format: config.format, + keyGenerated: wasKeyGenerated, +}); +``` + +--- + +### Phase 2: Setup & Configuration UI (2-3 weeks) + +#### 2.1 Setup Wizard + +Guide users through configuring Git's signing settings: + +1. **Detect Existing Setup** - Check if `user.signingkey` is configured +2. **Choose Format** - GPG, SSH, or X.509 +3. **Select/Generate Key** - Use existing or generate new +4. **Configure Git** - Write to Git config using `repo.git.config.setSigningConfig()` +5. **Test** - Create a test signed commit + +#### 2.2 Settings UI + +- Read-only display of current Git config values +- "Edit in Git Config" button +- "Run Setup Wizard" button +- "Test Signing" button +- GitLens-specific toggles (status bar, badges, etc.) + +--- + +### Phase 3: Signature Verification & Display (2-3 weeks) + +Show signature status in: + +- Commit Graph (badge on commits) +- Commit Details panel +- File History +- Search results + +**Badge Types:** + +- ✅ Valid (green) +- ⚠️ Expired/untrusted (yellow) +- ❌ Invalid (red) +- ⚪ Unsigned (gray) + +--- + +## File Structure + +``` +src/ +├── env/node/git/ +│ ├── git.ts # MODIFY: Add gitConfigsLogWithSignatures constant +│ └── sub-providers/ +│ ├── config.ts # MODIFY: Add signing config methods + getSigningConfigFlags() +│ ├── commits.ts # MODIFY: Add signature parsing + getCommitSignature() +│ └── patch.ts # MODIFY: Add sign option to both createUnreachable* methods +├── git/ +│ ├── gitProvider.ts # MODIFY: Extend GitConfigSubProvider & GitCommitsSubProvider interfaces +│ └── models/ +│ ├── commit.ts # MODIFY: Add signature property + getSignature() method +│ ├── signature.ts # NEW: SigningConfig, CommitSignature, ValidationResult types +│ └── errors.ts # MODIFY: Add SigningError class +├── webviews/plus/composer/ +│ └── composerWebview.ts # MODIFY: Pass sign option to createUnreachableCommitsFromPatches +├── plus/signing/ # NEW: UI/UX features (Phase 2+) +│ ├── setupWizard.ts # NEW: Setup wizard logic +│ └── keyGenerator.ts # NEW: Generate keys (optional) +└── config.ts # MODIFY: Add minimal GitLens settings (showSetupWizard, etc.) +``` + +### Files Modified (Phase 1) + +1. **src/env/node/git/git.ts** - Add `gitConfigsLogWithSignatures` constant +2. **src/env/node/git/sub-providers/config.ts** - Add 4 new methods (getSigningConfig, validateSigningSetup, setSigningConfig, getSigningConfigFlags) +3. **src/env/node/git/sub-providers/commits.ts** - Add 2 new methods (getCommitSignature, parseSignature) +4. **src/env/node/git/sub-providers/patch.ts** - Modify 3 methods (createUnreachableCommitForPatchCore, createUnreachableCommitForPatch, createUnreachableCommitsFromPatches) +5. **src/git/gitProvider.ts** - Extend 2 interfaces (GitConfigSubProvider, GitCommitsSubProvider) +6. **src/git/models/commit.ts** - Add signature property + getSignature() method +7. **src/git/models/signature.ts** - NEW file with 3 type definitions +8. **src/git/models/errors.ts** - Add SigningError class +9. **src/webviews/plus/composer/composerWebview.ts** - Pass sign option when creating commits + +**Total: 8 files modified, 1 file created** + +--- + +## Backward Compatibility + +All existing code continues to work without changes. The `options` parameter is optional on all modified methods: + +```typescript +createUnreachableCommitForPatch(..., options?: { sign?: boolean; source?: Source }) +createUnreachableCommitsFromPatches(..., options?: { sign?: boolean; source?: Source }) +``` + +Existing calls without the `options` parameter will: + +1. Check Git config for `commit.gpgsign` +2. Sign if enabled, skip if disabled +3. Maintain existing behavior for repos without signing configured + +**Verified call sites** (all compatible): + +- `src/commands/patches.ts` - Paste patch command +- `src/webviews/plus/patchDetails/patchDetailsWebview.ts` - Patch details +- `src/commands/generateRebase.ts` - AI-generated rebase +- `src/commands/git/worktree.ts` - Copy changes to worktree + +--- + +## Implementation Patterns + +### Lazy Signature Loading + +Uses `undefined | null` caching pattern (matches existing GitLens patterns like `_message`): + +- `undefined` = not yet fetched +- `null` = fetched but no signature found +- `CommitSignature` = signature found and cached + +### Why `log.showSignature=false` Stays in `gitConfigsLog` + +GitLens explicitly disables signatures in normal log operations for performance. The `gitConfigsLogWithSignatures` constant is used only when signatures are explicitly requested. This ensures: + +1. Fast log operations by default +2. Signatures only fetched when explicitly requested (lazy loading) +3. Consistent behavior regardless of user's global Git config + +--- + +## Benefits + +✅ **No Configuration Duplication** - Single source of truth (Git config) +✅ **Works with VS Code** - Both extensions read same Git config +✅ **Works with CLI** - Git CLI and GitLens stay in sync +✅ **Simpler Implementation** - Extends existing code, no new sub-provider +✅ **Better UX** - Users configure Git once, works everywhere +✅ **Smaller Surface Area** - Fewer files to create/modify + +--- + +## API Reference + +### Type Definitions + +```typescript +type SigningFormat = 'gpg' | 'ssh' | 'x509' | 'openpgp'; +type SignatureStatus = 'good' | 'bad' | 'unknown' | 'expired' | 'revoked' | 'error'; +type TrustLevel = 'ultimate' | 'full' | 'marginal' | 'never' | 'unknown'; + +interface SigningConfig { + enabled: boolean; // commit.gpgsign + format: SigningFormat; // gpg.format + signingKey?: string; // user.signingkey + gpgProgram?: string; // gpg.program + sshProgram?: string; // gpg.ssh.program + allowedSignersFile?: string; // gpg.ssh.allowedSignersFile +} + +interface CommitSignature { + status: SignatureStatus; + signer?: string; + keyId?: string; + fingerprint?: string; + timestamp?: Date; + errorMessage?: string; + trustLevel?: TrustLevel; +} + +interface ValidationResult { + valid: boolean; + error?: string; +} +``` + +### ConfigGitSubProvider Methods + +| Method | Description | +| ------------------------------------ | ------------------------------------- | +| `getSigningConfig(repoPath)` | Reads all signing config from Git | +| `validateSigningSetup(repoPath)` | Validates GPG/SSH is available | +| `setSigningConfig(repoPath, config)` | Writes signing config to Git | +| `getSigningConfigFlags(config)` | Generates `-c` flags for Git commands | + +### CommitsGitSubProvider Methods + +| Method | Description | +| ----------------------------------- | ----------------------------------------- | +| `getCommitSignature(repoPath, sha)` | Fetches and parses signature for a commit | + +### GitCommit Methods + +| Method | Description | +| ---------------- | ---------------------------------------------- | +| `getSignature()` | Lazy-loads signature (cached after first call) | + +### PatchGitSubProvider Options + +```typescript +// Both methods accept options: +createUnreachableCommitForPatch(..., options?: { sign?: boolean; source?: Source }) +createUnreachableCommitsFromPatches(..., options?: { sign?: boolean; source?: Source }) +``` + +- `sign: true` → Always sign +- `sign: false` → Never sign +- `sign: undefined` → Respect `commit.gpgsign` config + +### Usage Patterns + +```typescript +// Check if signing is enabled +const config = await repo.git.config.getSigningConfig(); +if (config.enabled) { + console.log(`Using ${config.format} signing`); +} + +// Validate before signing +const validation = await repo.git.config.validateSigningSetup(); +if (!validation.valid) { + throw new Error(`Signing not configured: ${validation.error}`); +} + +// Display signature status +const signature = await commit.getSignature(); +if (signature?.status === 'good') { + console.log(`✓ Verified signature from ${signature.signer}`); +} + +// Error handling +if (SigningError.is(ex, SigningErrorReason.NoKey)) { + console.error('No signing key configured'); +} +``` + +--- + +## Testing Guide + +### Quick Setup + +**GPG Signing:** + +```bash +gpg --full-generate-key # Generate key +gpg --list-secret-keys --keyid-format=long # List keys +git config --global user.signingkey KEYID +git config --global commit.gpgsign true +git config --global gpg.format gpg +``` + +**SSH Signing (Git 2.34+):** + +```bash +ssh-keygen -t ed25519 -C "your@email.com" +git config --global user.signingkey ~/.ssh/id_ed25519.pub +git config --global commit.gpgsign true +git config --global gpg.format ssh +echo "$(git config user.email) $(cat ~/.ssh/id_ed25519.pub)" > ~/.ssh/allowed_signers +git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers +``` + +### Manual Test Cases + +| Test | Steps | Expected | +| ---------------- | ------------------------------------------ | ------------------------------------- | +| Read Config | `repo.git.config.getSigningConfig()` | Returns correct SigningConfig | +| Validate Setup | `repo.git.config.validateSigningSetup()` | Returns `{valid: true}` if configured | +| Parse Signature | `repo.git.commits.getCommitSignature(sha)` | Returns CommitSignature with status | +| Lazy Loading | Call `commit.getSignature()` twice | Second call returns cached value | +| Composer Signing | Create commit with `commit.gpgsign=true` | Commit is signed | +| Disable Signing | Set `commit.gpgsign=false`, create commit | Commit is unsigned | +| Force Signing | Pass `{sign: true}` to patch methods | Commit is signed regardless of config | +| Missing Key | Unset `user.signingkey`, validate | Returns error | + +### Troubleshooting + +**GPG Agent Issues:** + +```bash +export GPG_TTY=$(tty) +``` + +**SSH Signing Issues:** + +- Verify Git version ≥ 2.34: `git --version` +- Check allowed signers file exists and is readable diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index a3932db2ece67..3de656215b4b2 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -806,6 +806,48 @@ or } ``` +### commit/signed + +> Sent when a commit is signed + +```typescript +{ + 'format': 'gpg' | 'ssh' | 'x509' | 'openpgp' +} +``` + +### commit/signing/failed + +> Sent when commit signing fails + +```typescript +{ + 'format': 'gpg' | 'ssh' | 'x509' | 'openpgp', + 'reason': 'unknown' | 'noKey' | 'gpgNotFound' | 'sshNotFound' | 'passphraseFailed' +} +``` + +### commit/signing/setup + +> Sent when commit signing setup is completed + +```typescript +{ + 'format': 'gpg' | 'ssh' | 'x509' | 'openpgp', + 'keyGenerated': boolean +} +``` + +### commit/signing/setupWizard/opened + +> Sent when commit signing setup wizard is opened + +```typescript +{ + 'alreadyConfigured': boolean +} +``` + ### commitDetails/closed ```typescript diff --git a/package.json b/package.json index 58cbf5d1223e7..dc2bc27244236 100644 --- a/package.json +++ b/package.json @@ -5632,6 +5632,41 @@ "markdownDeprecationMessage": "Deprecated. Use the pre-release of GitLens instead" } } + }, + { + "id": "commit-signing", + "title": "Commit Signing", + "order": 1400, + "properties": { + "gitlens.signing.showSetupWizard": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the commit signing setup wizard when signing is not configured", + "scope": "window", + "order": 10 + }, + "gitlens.signing.showStatusBar": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the commit signing status in the status bar", + "scope": "window", + "order": 20 + }, + "gitlens.signing.showSignatureBadges": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show signature verification badges on commits in the Commit Graph and other views", + "scope": "window", + "order": 30 + }, + "gitlens.signing.enableKeyGeneration": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable the ability to generate signing keys from within GitLens", + "scope": "window", + "order": 40 + } + } } ], "configurationDefaults": { @@ -7034,6 +7069,12 @@ "title": "Checkout Pull Request in Worktree (GitLens)...", "enablement": "!operationInProgress" }, + { + "command": "gitlens.git.setupCommitSigning", + "title": "Setup Commit Signing...", + "category": "GitLens", + "icon": "$(key)" + }, { "command": "gitlens.gitCommands", "title": "Git Command Palette", @@ -12244,6 +12285,10 @@ "command": "gitlens.ghpr.views.openOrCreateWorktree", "when": "false" }, + { + "command": "gitlens.git.setupCommitSigning", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, { "command": "gitlens.gitCommands", "when": "!gitlens:disabled" diff --git a/src/commands.ts b/src/commands.ts index bb74e760e40f0..b69ea6b26f063 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -73,6 +73,7 @@ import './commands/showQuickFileHistory.js'; import './commands/showQuickRepoStatus.js'; import './commands/showQuickStashList.js'; import './commands/showView.js'; +import './commands/signing/setup.js'; import './commands/stashApply.js'; import './commands/stashSave.js'; import './commands/switchMode.js'; diff --git a/src/commands/signing/setup.ts b/src/commands/signing/setup.ts new file mode 100644 index 0000000000000..a4ced0864002b --- /dev/null +++ b/src/commands/signing/setup.ts @@ -0,0 +1,143 @@ +import { window } from 'vscode'; +import type { Container } from '../../container.js'; +import { command } from '../../system/-webview/command.js'; +import { GlCommandBase } from '../commandBase.js'; + +export interface SetupSigningWizardCommandArgs { + readonly repoPath?: string; +} + +@command() +export class SetupSigningWizardCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super('gitlens.git.setupCommitSigning'); + } + + async execute(args?: SetupSigningWizardCommandArgs): Promise { + // Get the repository + const repository = args?.repoPath + ? this.container.git.getRepository(args.repoPath) + : this.container.git.getBestRepository(); + + if (repository == null) { + void window.showErrorMessage('Unable to find a repository to configure signing for'); + return; + } + + // Check if signing is already configured + const signingConfig = await repository.git.config.getSigningConfig?.(); + const alreadyConfigured = Boolean(signingConfig?.enabled && signingConfig?.signingKey); + + // Send telemetry event + this.container.telemetry.sendEvent('commit/signing/setupWizard/opened', { + alreadyConfigured: alreadyConfigured, + }); + + if (alreadyConfigured) { + const result = await window.showInformationMessage( + `Commit signing is already configured for this repository using ${signingConfig?.format?.toUpperCase() ?? 'GPG'}.`, + { modal: false }, + 'Reconfigure', + 'Test Signing', + ); + + if (result === 'Test Signing') { + await this.testSigning(repository); + return; + } else if (result !== 'Reconfigure') { + return; + } + } + + // Show setup wizard + await this.showSetupWizard(repository); + } + + private async showSetupWizard(repository: ReturnType): Promise { + if (repository == null) return; + // TODO: Implement full setup wizard UI + // For now, show a simple quick pick to choose signing format + + const format = await window.showQuickPick( + [ + { + label: '$(key) GPG', + description: 'Sign commits with GPG', + detail: 'Uses GPG (GNU Privacy Guard) for signing commits', + value: 'gpg' as const, + }, + { + label: '$(key) SSH', + description: 'Sign commits with SSH', + detail: 'Uses SSH keys for signing commits (requires Git 2.34+)', + value: 'ssh' as const, + }, + { + label: '$(key) X.509', + description: 'Sign commits with X.509', + detail: 'Uses X.509 certificates for signing commits', + value: 'x509' as const, + }, + ], + { + title: 'Commit Signing Setup', + placeHolder: 'Choose a signing format', + ignoreFocusOut: true, + }, + ); + + if (format == null) return; + + // Get signing key + const signingKey = await window.showInputBox({ + title: 'Commit Signing Setup', + prompt: `Enter your ${format.value.toUpperCase()} signing key ${format.value === 'ssh' ? '(file path)' : '(key ID)'}`, + placeHolder: format.value === 'ssh' ? '~/.ssh/id_ed25519.pub' : 'Your key ID', + ignoreFocusOut: true, + }); + + if (!signingKey) return; + + // Configure Git + try { + await repository.git.config.setSigningConfig?.({ + enabled: true, + format: format.value, + signingKey: signingKey, + }); + + const result = await window.showInformationMessage( + `Commit signing has been configured successfully using ${format.value.toUpperCase()}.`, + { modal: false }, + 'Test Signing', + ); + + if (result === 'Test Signing') { + await this.testSigning(repository); + } + + // Send telemetry event for successful setup + this.container.telemetry.sendEvent('commit/signing/setup', { + format: format.value, + keyGenerated: false, // We don't support key generation yet + }); + } catch (ex) { + void window.showErrorMessage( + `Failed to configure commit signing: ${ex instanceof Error ? ex.message : String(ex)}`, + ); + } + } + + private async testSigning(repository: ReturnType): Promise { + if (repository == null) return; + + // Validate signing setup + const validation = await repository.git.config.validateSigningSetup?.(); + + if (validation?.valid) { + void window.showInformationMessage('✓ Commit signing is configured correctly and ready to use.'); + } else { + void window.showWarningMessage(`Commit signing validation failed: ${validation?.error ?? 'Unknown error'}`); + } + } +} diff --git a/src/config.ts b/src/config.ts index fe38ccd98c034..54a15fd0c2c0a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,7 @@ export interface Config { readonly rebaseEditor: RebaseEditorConfig; readonly remotes: RemotesConfig[] | null; readonly showWhatsNewAfterUpgrades: boolean; + readonly signing: SigningConfig; readonly sortBranchesBy: BranchSorting; readonly sortContributorsBy: ContributorSorting; readonly sortTagsBy: TagSorting; @@ -706,6 +707,13 @@ export interface RemotesUrlsConfig { readonly fileRange: string; } +interface SigningConfig { + readonly showSetupWizard: boolean; + readonly showStatusBar: boolean; + readonly showSignatureBadges: boolean; + readonly enableKeyGeneration: boolean; +} + interface StatusBarConfig { readonly alignment: 'left' | 'right'; readonly command: StatusBarCommands; diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index ef166adfe8a1b..0c3fcc5fe134f 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -886,6 +886,7 @@ export type ContributedPaletteCommands = | 'gitlens.externalDiffAll' | 'gitlens.fetchRepositories' | 'gitlens.getStarted' + | 'gitlens.git.setupCommitSigning' | 'gitlens.gitCommands' | 'gitlens.gitCommands.branch' | 'gitlens.gitCommands.branch.create' diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 5698d4ba6f274..63725feb8f9b1 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -141,6 +141,15 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE /** Sent when a VS Code command is executed by a GitLens provided action */ 'command/core': CoreCommandEvent; + /** Sent when a commit is signed */ + 'commit/signed': CommitSignedEvent; + /** Sent when commit signing fails */ + 'commit/signing/failed': CommitSigningFailedEvent; + /** Sent when commit signing setup is completed */ + 'commit/signing/setup': CommitSigningSetupEvent; + /** Sent when commit signing setup wizard is opened */ + 'commit/signing/setupWizard/opened': CommitSigningSetupWizardOpenedEvent; + /** Sent when the Inspect view is shown */ 'commitDetails/shown': DetailsShownEvent; /** Sent when the user changes the selected tab (mode) on the Graph Details view */ @@ -713,6 +722,24 @@ interface DetailsReachabilityFailedEvent { 'failed.error'?: string; } +interface CommitSignedEvent { + format: 'gpg' | 'ssh' | 'x509' | 'openpgp'; +} + +interface CommitSigningFailedEvent { + reason: 'noKey' | 'gpgNotFound' | 'sshNotFound' | 'passphraseFailed' | 'unknown'; + format: 'gpg' | 'ssh' | 'x509' | 'openpgp'; +} + +interface CommitSigningSetupEvent { + format: 'gpg' | 'ssh' | 'x509' | 'openpgp'; + keyGenerated: boolean; +} + +interface CommitSigningSetupWizardOpenedEvent { + alreadyConfigured: boolean; +} + export type FeaturePreviewDayEventData = Record<`day.${number}.startedOn`, string>; export type FeaturePreviewEventData = { feature: FeaturePreviews; diff --git a/src/constants.ts b/src/constants.ts index 60616c9f8d996..0c6017c68ce16 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -73,6 +73,14 @@ export type GitConfigKeys = | `branch.${string}.gk-last-accessed` | `branch.${string}.gk-last-modified`; +export type GitCoreConfigKeys = + | 'commit.gpgsign' + | 'gpg.format' + | 'gpg.program' + | 'gpg.ssh.program' + | 'gpg.ssh.allowedSignersFile' + | 'user.signingkey'; + export type DeprecatedGitConfigKeys = `branch.${string}.gk-target-base`; export const enum GlyphChars { diff --git a/src/container.ts b/src/container.ts index 53e77f4b75033..8d9bcce5e2e03 100644 --- a/src/container.ts +++ b/src/container.ts @@ -51,6 +51,7 @@ import { RepositoryIdentityService } from './plus/repos/repositoryIdentityServic import type { SharedGkStorageLocationProvider } from './plus/repos/sharedGkStorageLocationProvider.js'; import { WorkspacesApi } from './plus/workspaces/workspacesApi.js'; import { scheduleAddMissingCurrentWorkspaceRepos, WorkspacesService } from './plus/workspaces/workspacesService.js'; +import { SigningStatusBarController } from './statusbar/signingStatusBarController.js'; import { StatusBarController } from './statusbar/statusBarController.js'; import { executeCommand } from './system/-webview/command.js'; import { configuration } from './system/-webview/configuration.js'; @@ -239,6 +240,7 @@ export class Container { this._disposables.push((this._lineAnnotationController = new LineAnnotationController(this))); this._disposables.push((this._lineHoverController = new LineHoverController(this))); this._disposables.push((this._statusBarController = new StatusBarController(this))); + this._disposables.push(new SigningStatusBarController(this)); this._disposables.push((this._codeLensController = new GitCodeLensController(this))); const webviewCommandRegistrar = new WebviewCommandRegistrar(); diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 5d613022d3d4c..67f24e13aade8 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -72,6 +72,7 @@ export const gitConfigsBranch = ['-c', 'color.branch=false'] as const; export const gitConfigsDiff = ['-c', 'color.diff=false', '-c', 'diff.mnemonicPrefix=false'] as const; export const gitConfigsLog = ['-c', 'log.showSignature=false'] as const; export const gitConfigsLogWithFiles = ['-c', 'log.showSignature=false', '-c', 'diff.renameLimit=0'] as const; +export const gitConfigsLogWithSignatures = ['-c', 'log.showSignature=true'] as const; export const gitConfigsPull = ['-c', 'merge.autoStash=true', '-c', 'rebase.autoStash=true'] as const; export const gitConfigsStatus = ['-c', 'color.status=false'] as const; diff --git a/src/env/node/git/sub-providers/commits.ts b/src/env/node/git/sub-providers/commits.ts index afe29afeffac2..b7664d37be23d 100644 --- a/src/env/node/git/sub-providers/commits.ts +++ b/src/env/node/git/sub-providers/commits.ts @@ -27,6 +27,7 @@ import type { GitFileStatus } from '../../../../git/models/fileStatus.js'; import type { GitLog } from '../../../../git/models/log.js'; import type { GitReflog } from '../../../../git/models/reflog.js'; import type { GitRevisionRange } from '../../../../git/models/revision.js'; +import type { CommitSignature } from '../../../../git/models/signature.js'; import type { GitUser } from '../../../../git/models/user.js'; import type { CommitsInFileRangeLogParser, @@ -60,7 +61,7 @@ import { createDisposable } from '../../../../system/unifiedDisposable.js'; import type { CachedLog, TrackedGitDocument } from '../../../../trackers/trackedDocument.js'; import { GitDocumentState } from '../../../../trackers/trackedDocument.js'; import type { Git, GitResult } from '../git.js'; -import { gitConfigsLog, gitConfigsLogWithFiles } from '../git.js'; +import { gitConfigsLog, gitConfigsLogWithFiles, gitConfigsLogWithSignatures } from '../git.js'; import type { LocalGitProviderInternal } from '../localGitProvider.js'; import { convertStashesToStdin } from './stash.js'; @@ -1280,6 +1281,121 @@ export class CommitsGitSubProvider implements GitCommitsSubProvider { return { search: search, log: undefined }; } } + + @log() + async getCommitSignature(repoPath: string, sha: string): Promise { + const scope = getLogScope(); + + try { + // Use gitConfigsLogWithSignatures to enable signature display + const result = await this.git.exec( + { cwd: repoPath, configs: gitConfigsLogWithSignatures, errors: GitErrorHandling.Ignore }, + 'log', + '--show-signature', + '--format=%H', + '-1', + sha, + ); + + if (!result.stdout) return undefined; + + return this.parseSignature(result.stdout); + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + private parseSignature(output: string): CommitSignature | undefined { + // Parse GPG/SSH signature output + // Example GPG output: + // gpg: Signature made ... + // gpg: Good signature from "Name " + // Example SSH output: + // Good "git" signature for user@example.com with ED25519 key SHA256:... + + const lines = output.split('\n'); + let status: CommitSignature['status'] = 'unknown'; + let signer: string | undefined; + let keyId: string | undefined; + let fingerprint: string | undefined; + let trustLevel: CommitSignature['trustLevel'] = 'unknown'; + + for (const line of lines) { + const trimmed = line.trim(); + + // GPG signatures + if (trimmed.includes('Good signature from')) { + status = 'good'; + // Extract signer: "Good signature from "Name "" + const match = /Good signature from "([^"]+)"/.exec(trimmed); + if (match) { + signer = match[1]; + } + } else if (trimmed.includes('BAD signature from')) { + status = 'bad'; + const match = /BAD signature from "([^"]+)"/.exec(trimmed); + if (match) { + signer = match[1]; + } + } else if (trimmed.includes('expired')) { + status = 'expired'; + } else if (trimmed.includes('revoked')) { + status = 'revoked'; + } else if (trimmed.includes('error')) { + status = 'error'; + } + + // SSH signatures + if (trimmed.includes('Good "git" signature for')) { + status = 'good'; + // Extract signer: "Good "git" signature for user@example.com" + const match = /Good "git" signature for ([^\s]+)/.exec(trimmed); + if (match) { + signer = match[1]; + } + } + + // Extract key ID + if (trimmed.includes('key ID')) { + const match = /key ID ([A-F0-9]+)/.exec(trimmed); + if (match) { + keyId = match[1]; + } + } + + // Extract fingerprint + if (trimmed.includes('Primary key fingerprint:')) { + fingerprint = trimmed.replace('Primary key fingerprint:', '').trim(); + } else if (trimmed.includes('SHA256:')) { + const match = /SHA256:([A-Za-z0-9+/=]+)/.exec(trimmed); + if (match) { + fingerprint = `SHA256:${match[1]}`; + } + } + + // Extract trust level + if (trimmed.includes('[ultimate]')) { + trustLevel = 'ultimate'; + } else if (trimmed.includes('[full]')) { + trustLevel = 'full'; + } else if (trimmed.includes('[marginal]')) { + trustLevel = 'marginal'; + } else if (trimmed.includes('[never]')) { + trustLevel = 'never'; + } + } + + if (status === 'unknown') return undefined; + + return { + status: status, + signer: signer, + keyId: keyId, + fingerprint: fingerprint, + trustLevel: trustLevel, + }; + } } function createCommit( diff --git a/src/env/node/git/sub-providers/config.ts b/src/env/node/git/sub-providers/config.ts index 907c5bf4cce4c..08ac5445e52a1 100644 --- a/src/env/node/git/sub-providers/config.ts +++ b/src/env/node/git/sub-providers/config.ts @@ -1,11 +1,12 @@ import { hostname, userInfo } from 'os'; import { env as process_env } from 'process'; import { Uri } from 'vscode'; -import type { DeprecatedGitConfigKeys, GitConfigKeys } from '../../../../constants.js'; +import type { DeprecatedGitConfigKeys, GitConfigKeys, GitCoreConfigKeys } from '../../../../constants.js'; import type { Container } from '../../../../container.js'; import type { GitCache } from '../../../../git/cache.js'; import { GitErrorHandling } from '../../../../git/commandOptions.js'; import type { GitConfigSubProvider, GitDir } from '../../../../git/gitProvider.js'; +import type { SigningConfig, SigningFormat, ValidationResult } from '../../../../git/models/signature.js'; import type { GitUser } from '../../../../git/models/user.js'; import { getBestPath } from '../../../../system/-webview/path.js'; import { gate } from '../../../../system/decorators/gate.js'; @@ -27,12 +28,19 @@ export class ConfigGitSubProvider implements GitConfigSubProvider { ) {} @debug() - getConfig(repoPath: string, key: GitConfigKeys | DeprecatedGitConfigKeys): Promise { + getConfig( + repoPath: string, + key: GitCoreConfigKeys | GitConfigKeys | DeprecatedGitConfigKeys, + ): Promise { return this.git.config__get(key, repoPath); } @log() - async setConfig(repoPath: string, key: GitConfigKeys, value: string | undefined): Promise { + async setConfig( + repoPath: string, + key: GitCoreConfigKeys | GitConfigKeys, + value: string | undefined, + ): Promise { await this.git.exec( { cwd: repoPath ?? '', local: true }, 'config', @@ -147,4 +155,83 @@ export class ConfigGitSubProvider implements GitConfigSubProvider { return gitDir; } + + @log() + async getSigningConfig(repoPath: string): Promise { + const [enabled, format, signingKey, gpgProgram, sshProgram, allowedSignersFile] = await Promise.all([ + this.getConfig(repoPath, 'commit.gpgsign'), + this.getConfig(repoPath, 'gpg.format'), + this.getConfig(repoPath, 'user.signingkey'), + this.getConfig(repoPath, 'gpg.program'), + this.getConfig(repoPath, 'gpg.ssh.program'), + this.getConfig(repoPath, 'gpg.ssh.allowedSignersFile'), + ]); + + return { + enabled: enabled === 'true', + format: (format as SigningFormat) ?? 'gpg', + signingKey: signingKey, + gpgProgram: gpgProgram, + sshProgram: sshProgram, + allowedSignersFile: allowedSignersFile, + }; + } + + @log() + async validateSigningSetup(repoPath: string): Promise { + const config = await this.getSigningConfig(repoPath); + + if (!config.signingKey) { + return { valid: false, error: 'No signing key configured' }; + } + + // Basic validation: just check that a signing key is configured + // Git will handle the actual validation when signing commits + return { valid: true }; + } + + @log() + async setSigningConfig(repoPath: string, config: Partial): Promise { + const updates: Promise[] = []; + + if (config.enabled !== undefined) { + updates.push(this.setConfig(repoPath, 'commit.gpgsign', config.enabled ? 'true' : 'false')); + } + if (config.format !== undefined) { + updates.push(this.setConfig(repoPath, 'gpg.format', config.format)); + } + if (config.signingKey !== undefined) { + updates.push(this.setConfig(repoPath, 'user.signingkey', config.signingKey)); + } + if (config.gpgProgram !== undefined) { + updates.push(this.setConfig(repoPath, 'gpg.program', config.gpgProgram)); + } + if (config.sshProgram !== undefined) { + updates.push(this.setConfig(repoPath, 'gpg.ssh.program', config.sshProgram)); + } + if (config.allowedSignersFile !== undefined) { + updates.push(this.setConfig(repoPath, 'gpg.ssh.allowedSignersFile', config.allowedSignersFile)); + } + + await Promise.all(updates); + } + + getSigningConfigFlags(config: SigningConfig): string[] { + const flags: string[] = []; + + if (config.gpgProgram) { + flags.push('-c', `gpg.program=${config.gpgProgram}`); + } + if (config.format && config.format !== 'gpg') { + flags.push('-c', `gpg.format=${config.format}`); + } + if (config.sshProgram) { + flags.push('-c', `gpg.ssh.program=${config.sshProgram}`); + } + if (config.allowedSignersFile) { + flags.push('-c', `gpg.ssh.allowedSignersFile=${config.allowedSignersFile}`); + } + + return flags; + } } diff --git a/src/env/node/git/sub-providers/patch.ts b/src/env/node/git/sub-providers/patch.ts index 863a802ebd8e9..e5f43bc737845 100644 --- a/src/env/node/git/sub-providers/patch.ts +++ b/src/env/node/git/sub-providers/patch.ts @@ -1,9 +1,11 @@ import { window } from 'vscode'; +import type { Source } from '../../../../constants.telemetry.js'; import type { Container } from '../../../../container.js'; import { CancellationError } from '../../../../errors.js'; -import { ApplyPatchCommitError, CherryPickError } from '../../../../git/errors.js'; +import { ApplyPatchCommitError, CherryPickError, SigningError, SigningErrorReason } from '../../../../git/errors.js'; import type { GitPatchSubProvider } from '../../../../git/gitProvider.js'; import type { GitCommit, GitCommitIdentityShape } from '../../../../git/models/commit.js'; +import type { SigningFormat } from '../../../../git/models/signature.js'; import { log } from '../../../../system/decorators/log.js'; import { Logger } from '../../../../system/logger.js'; import { getLogScope } from '../../../../system/logger.scope.js'; @@ -130,12 +132,21 @@ export class PatchGitSubProvider implements GitPatchSubProvider { base: string, message: string, patch: string, + options?: { sign?: boolean; source?: Source }, ): Promise { // Create a temporary index file await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, base); const { env } = disposableIndex; - const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch); + const sha = await this.createUnreachableCommitForPatchCore( + env, + repoPath, + base, + message, + patch, + undefined, + options, + ); // eslint-disable-next-line no-return-await -- await is needed for the disposableIndex to be disposed properly after return await this.provider.commits.getCommit(repoPath, sha); } @@ -145,6 +156,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { repoPath: string, base: string | undefined, patches: { message: string; patch: string; author?: GitCommitIdentityShape }[], + options?: { sign?: boolean; source?: Source }, ): Promise { // Create a temporary index file await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, base); @@ -153,7 +165,15 @@ export class PatchGitSubProvider implements GitPatchSubProvider { const shas: string[] = []; for (const { message, patch, author } of patches) { - const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch, author); + const sha = await this.createUnreachableCommitForPatchCore( + env, + repoPath, + base, + message, + patch, + author, + options, + ); shas.push(sha); base = sha; } @@ -168,6 +188,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { message: string, patch: string, author?: GitCommitIdentityShape, + options?: { sign?: boolean; source?: Source }, ): Promise { const scope = getLogScope(); @@ -175,6 +196,11 @@ export class PatchGitSubProvider implements GitPatchSubProvider { patch += '\n'; } + // Check if we should sign + const signingConfig = await this.provider.config.getSigningConfig?.(repoPath); + const shouldSign = options?.sign ?? signingConfig?.enabled ?? false; + const signingFormat: SigningFormat = signingConfig?.format ?? 'gpg'; + try { // Apply the patch to our temp index, without touching the working directory await this.git.exec( @@ -189,34 +215,95 @@ export class PatchGitSubProvider implements GitPatchSubProvider { 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; + if (author) { + env = { + ...env, + GIT_AUTHOR_NAME: author.name, + GIT_AUTHOR_EMAIL: author.email || '', + }; + } // Create new commit from the tree - result = await this.git.exec( - { cwd: repoPath, env: commitEnv }, - 'commit-tree', - tree, - ...(base ? ['-p', base] : []), - '-m', - message, - ); + const args = ['commit-tree', tree]; + if (base) { + args.push('-p', base); + } + + // Add signing flag if enabled + if (shouldSign) { + args.push('-S'); + } + + args.push('-m', message); + + // Create new commit from the tree + result = await this.git.exec({ cwd: repoPath, env: env }, ...args); const sha = result.stdout.trim(); + // Send telemetry for successful signed commit + if (shouldSign) { + this.container.telemetry.sendEvent('commit/signed', { format: signingFormat }, options?.source); + } + return sha; } catch (ex) { Logger.error(ex, scope); - debugger; + // Handle signing-specific errors + if (shouldSign && ex instanceof Error) { + const errorMessage = ex.message.toLowerCase(); + let signingError: SigningError | undefined; + + if (errorMessage.includes('gpg failed to sign') || errorMessage.includes('error: gpg')) { + signingError = new SigningError(SigningErrorReason.PassphraseFailed, ex, ex.message); + } else if ( + errorMessage.includes('secret key not available') || + errorMessage.includes('no secret key') || + errorMessage.includes('no signing key') + ) { + signingError = new SigningError(SigningErrorReason.NoKey, ex, ex.message); + } else if ( + errorMessage.includes('gpg: command not found') || + (errorMessage.includes('gpg') && errorMessage.includes('not found')) + ) { + signingError = new SigningError(SigningErrorReason.GpgNotFound, ex, ex.message); + } else if (errorMessage.includes('ssh-keygen') && errorMessage.includes('not found')) { + signingError = new SigningError(SigningErrorReason.SshNotFound, ex, ex.message); + } + + if (signingError != null) { + // Send telemetry for signing failure + this.container.telemetry.sendEvent( + 'commit/signing/failed', + { reason: this.getSigningFailureReason(signingError.reason), format: signingFormat }, + options?.source, + ); + throw signingError; + } + } + + debugger; throw ex; } } + private getSigningFailureReason( + reason: SigningErrorReason | undefined, + ): 'noKey' | 'gpgNotFound' | 'sshNotFound' | 'passphraseFailed' | 'unknown' { + switch (reason) { + case SigningErrorReason.NoKey: + return 'noKey'; + case SigningErrorReason.GpgNotFound: + return 'gpgNotFound'; + case SigningErrorReason.SshNotFound: + return 'sshNotFound'; + case SigningErrorReason.PassphraseFailed: + return 'passphraseFailed'; + default: + return 'unknown'; + } + } + async createEmptyInitialCommit(repoPath: string): Promise { const emptyTree = await this.git.exec({ cwd: repoPath }, 'hash-object', '-t', 'tree', '/dev/null'); const result = await this.git.exec({ cwd: repoPath }, 'commit-tree', emptyTree.stdout.trim(), '-m', 'temp'); diff --git a/src/git/errors.ts b/src/git/errors.ts index f6ff530579d45..991b7a52a1207 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -796,3 +796,58 @@ export class WorktreeDeleteError extends GitCommandError; + + getCommitSignature?(repoPath: string, sha: string): Promise; } export interface GitOperationsSubProvider { @@ -456,6 +459,10 @@ export interface GitConfigSubProvider { getCurrentUser(repoPath: string): Promise; getDefaultWorktreePath?(repoPath: string): Promise; getGitDir?(repoPath: string): Promise; + getSigningConfig?(repoPath: string): Promise; + validateSigningSetup?(repoPath: string): Promise; + setSigningConfig?(repoPath: string, config: Partial): Promise; + getSigningConfigFlags?(config: import('./models/signature').SigningConfig): string[]; } export interface GitContributorsResult { @@ -611,11 +618,13 @@ export interface GitPatchSubProvider { base: string, message: string, patch: string, + options?: { sign?: boolean; source?: Source }, ): Promise; createUnreachableCommitsFromPatches( repoPath: string, base: string | undefined, patches: { message: string; patch: string; author?: GitCommitIdentityShape }[], + options?: { sign?: boolean; source?: Source }, ): Promise; createEmptyInitialCommit(repoPath: string): Promise; diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index e5906a27a6e12..a14f3ba86d5e5 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -32,6 +32,7 @@ import type { GitRevisionReference, GitStashReference } from './reference.js'; import type { GitRemote } from './remote.js'; import type { Repository } from './repository.js'; import { uncommitted, uncommittedStaged } from './revision.js'; +import type { CommitSignature } from './signature.js'; const stashNumberRegex = /stash@{(\d+)}/; @@ -57,6 +58,7 @@ export interface GitCommitFileset { export class GitCommit implements GitRevisionReference { private _stashUntrackedFilesLoaded = false; private _recomputeStats = false; + private _signature: CommitSignature | undefined | null; readonly lines: GitCommitLine[]; readonly ref: string; @@ -586,6 +588,19 @@ export class GitCommit implements GitRevisionReference { return this.author.getCachedAvatarUri(options); } + async getSignature(): Promise { + if (this.isUncommitted) return undefined; + if (this._signature === null) return undefined; + if (this._signature !== undefined) return this._signature; + + // Fetch signature from git + this._signature = + (await this.container.git.getRepositoryService(this.repoPath).commits.getCommitSignature?.(this.sha)) ?? + null; + + return this._signature ?? undefined; + } + async getCommitForFile(file: string | GitFile, staged?: boolean): Promise { const path = typeof file === 'string' ? this.container.git.getRelativePath(file, this.repoPath) : file.path; const foundFile = await this.findFile(path, staged); diff --git a/src/git/models/signature.ts b/src/git/models/signature.ts new file mode 100644 index 0000000000000..deba519a19ac8 --- /dev/null +++ b/src/git/models/signature.ts @@ -0,0 +1,28 @@ +export type SigningFormat = 'gpg' | 'ssh' | 'x509' | 'openpgp'; + +export interface SigningConfig { + enabled: boolean; + format: SigningFormat; + signingKey?: string; + gpgProgram?: string; + sshProgram?: string; + allowedSignersFile?: string; +} + +export type SignatureStatus = 'good' | 'bad' | 'unknown' | 'expired' | 'revoked' | 'error'; +export type TrustLevel = 'ultimate' | 'full' | 'marginal' | 'never' | 'unknown'; + +export interface CommitSignature { + status: SignatureStatus; + signer?: string; + keyId?: string; + fingerprint?: string; + timestamp?: Date; + errorMessage?: string; + trustLevel?: TrustLevel; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} diff --git a/src/statusbar/signingStatusBarController.ts b/src/statusbar/signingStatusBarController.ts new file mode 100644 index 0000000000000..94d4519937cab --- /dev/null +++ b/src/statusbar/signingStatusBarController.ts @@ -0,0 +1,88 @@ +import type { ConfigurationChangeEvent, StatusBarItem } from 'vscode'; +import { Disposable, MarkdownString, StatusBarAlignment, window } from 'vscode'; +import type { GlCommands } from '../constants.commands.js'; +import type { Container } from '../container.js'; +import { configuration } from '../system/-webview/configuration.js'; +import { once } from '../system/event.js'; + +export class SigningStatusBarController implements Disposable { + private readonly _disposable: Disposable; + private _statusBarItem: StatusBarItem | undefined; + + constructor(private readonly container: Container) { + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + once(container.onReady)(() => queueMicrotask(() => this.updateStatusBar())), + container.git.onDidChangeRepositories(() => this.updateStatusBar()), + { dispose: () => this._statusBarItem?.dispose() }, + ); + } + + dispose(): void { + this._disposable.dispose(); + } + + private onConfigurationChanged(e?: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'signing.showStatusBar')) return; + + void this.updateStatusBar(); + } + + private async updateStatusBar() { + const enabled = configuration.get('signing.showStatusBar'); + + if (!enabled) { + this._statusBarItem?.dispose(); + this._statusBarItem = undefined; + return; + } + + // Get the best repository + const repository = this.container.git.getBestRepository(); + if (repository == null) { + this._statusBarItem?.dispose(); + this._statusBarItem = undefined; + return; + } + + // Get signing configuration + const signingConfig = await repository.git.config.getSigningConfig?.(); + + // Create status bar item if it doesn't exist + if (this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem( + 'gitlens.signing', + StatusBarAlignment.Left, + 10000 - 4, // Position after Launchpad (10000 - 3) + ); + this._statusBarItem.name = 'GitLens Commit Signing'; + this._statusBarItem.command = 'gitlens.git.setupCommitSigning' satisfies GlCommands; + } + + // Update status bar based on signing configuration + if (signingConfig?.enabled && signingConfig?.signingKey) { + // Signing is configured and enabled + const format = signingConfig.format.toUpperCase(); + this._statusBarItem.text = `$(key) ${format}`; + this._statusBarItem.tooltip = new MarkdownString( + `**Commit Signing: Enabled**\n\nFormat: ${format}\n\nClick to reconfigure or test signing`, + true, + ); + this._statusBarItem.accessibilityInformation = { + label: `Commit signing is enabled using ${format}. Click to reconfigure or test signing.`, + }; + } else { + // Signing is not configured + this._statusBarItem.text = '$(key) Sign'; + this._statusBarItem.tooltip = new MarkdownString( + '**Commit Signing: Not Configured**\n\nClick to setup commit signing', + true, + ); + this._statusBarItem.accessibilityInformation = { + label: 'Commit signing is not configured. Click to setup commit signing.', + }; + } + + this._statusBarItem.show(); + } +} diff --git a/src/webviews/plus/composer/composerWebview.ts b/src/webviews/plus/composer/composerWebview.ts index 8753ede443d9c..80f0a03229daf 100644 --- a/src/webviews/plus/composer/composerWebview.ts +++ b/src/webviews/plus/composer/composerWebview.ts @@ -1538,7 +1538,14 @@ export class ComposerWebviewProvider implements WebviewProvider