├── .eslintignore ├── .gitignore ├── tests ├── states │ ├── no-head │ ├── same-details │ ├── different-details │ ├── merge │ ├── rebase-ignore-ad │ ├── rebase-cd-is-ad │ ├── rebase-i │ └── rebase ├── prompt-continue.mjs ├── edit-rebase-todo.mjs └── create-test-repo.mjs ├── media ├── icon.png ├── dark │ └── icon.svg ├── icon.svg └── light │ └── icon.svg ├── .vscodeignore ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── tsconfig.json ├── .eslintrc.json ├── src ├── util.ts ├── types │ ├── messages.d.ts │ └── git.d.ts ├── webview │ ├── git-identity-input.ts │ ├── git-date-input.ts │ ├── timezone-input.ts │ ├── index.css │ ├── presets.ts │ ├── index.ts │ └── git-datum-input.ts └── extension.ts ├── CHANGELOG.md ├── webpack.config.ts ├── README.md ├── package.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | src/types/git.d.ts -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.vsix 4 | tests/test-repo -------------------------------------------------------------------------------- /tests/states/no-head: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/commit-with-date/main/media/icon.png -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | node_modules/** 3 | src/** 4 | tests/** 5 | .eslintignore 6 | .eslintrc.json 7 | .gitignore 8 | tsconfig.json 9 | webpack.config.ts 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "amodio.tsl-problem-matcher", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "dist": true 4 | }, 5 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 6 | "typescript.tsc.autoDetect": "off", 7 | "svg.preview.background": "transparent" 8 | } 9 | -------------------------------------------------------------------------------- /tests/prompt-continue.mjs: -------------------------------------------------------------------------------- 1 | import { createInterface } from 'node:readline'; 2 | import { stdin as input, stdout as output } from 'node:process'; 3 | 4 | const rl = createInterface({ input, output }); 5 | 6 | await new Promise((resolve) => { 7 | rl.question('Press enter to view log...', () => { 8 | rl.close(); 9 | resolve(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2019", 5 | "outDir": "out", 6 | "lib": ["DOM", "ES2019"], 7 | "sourceMap": false, 8 | "rootDir": ".", 9 | "strict": true, 10 | "esModuleInterop": true 11 | }, 12 | "exclude": ["node_modules", "webpack.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /media/dark/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /media/light/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /tests/states/same-details: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 5 | SET: GIT_AUTHOR_NAME = John 6 | SET: GIT_AUTHOR_EMAIL = john@localhost 7 | SET: GIT_COMMITTER_DATE = 2023-01-01 01:00:00 +00:00 8 | SET: GIT_COMMITTER_NAME = John 9 | SET: GIT_COMMITTER_EMAIL = john@localhost 10 | GIT: commit -m "commit" 11 | FILE: foo = grape 12 | GIT: add foo -------------------------------------------------------------------------------- /tests/edit-rebase-todo.mjs: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | 3 | const args = process.argv.slice(2); 4 | 5 | const position = +args[0]; 6 | const newCommand = args[1]; 7 | const file = args[2]; 8 | 9 | const content = await readFile(file, 'utf8'); 10 | 11 | const lines = content.split('\n'); 12 | lines[position] = lines[position].replace(/^\w+/, newCommand); 13 | 14 | await writeFile(file, lines.join('\n')); 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": "warn", 11 | "@typescript-eslint/semi": "warn", 12 | "eqeqeq": "warn", 13 | "no-throw-literal": "warn" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/states/different-details: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 5 | SET: GIT_AUTHOR_NAME = John Author 6 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 7 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 8 | SET: GIT_COMMITTER_NAME = John Committer 9 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 10 | GIT: commit -m "commit" 11 | FILE: foo = grape 12 | GIT: add foo -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export type Identity = { 2 | name: string; 3 | email: string; 4 | }; 5 | 6 | export function parseIdentity(identityStr: string): Identity { 7 | const match = /^(.*) <(.*)>/.exec(identityStr)!; 8 | 9 | return { 10 | name: match[1].trim(), 11 | email: match[2].trim(), 12 | }; 13 | } 14 | 15 | export function formatIdentity(identity: Identity): string { 16 | return `${identity.name} <${identity.email}>`; 17 | } 18 | -------------------------------------------------------------------------------- /tests/states/merge: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | GIT: commit -m "common commit" 5 | GIT: checkout -b alt 6 | FILE: foo = mango 7 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 8 | SET: GIT_AUTHOR_NAME = John Author 9 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 10 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 11 | SET: GIT_COMMITTER_NAME = John Committer 12 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 13 | GIT: commit -a -m "alt commit" 14 | UNSET: GIT_AUTHOR_DATE 15 | UNSET: GIT_AUTHOR_NAME 16 | UNSET: GIT_AUTHOR_EMAIL 17 | UNSET: GIT_COMMITTER_DATE 18 | UNSET: GIT_COMMITTER_NAME 19 | UNSET: GIT_COMMITTER_EMAIL 20 | GIT: checkout main 21 | FILE: foo = apple 22 | GIT: commit -a -m "main commit" 23 | GIT: checkout alt 24 | GIT: merge main -------------------------------------------------------------------------------- /tests/states/rebase-ignore-ad: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | GIT: commit -m "common commit" 5 | GIT: checkout -b alt 6 | FILE: foo = mango 7 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 8 | SET: GIT_AUTHOR_NAME = John Author 9 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 10 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 11 | SET: GIT_COMMITTER_NAME = John Committer 12 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 13 | GIT: commit -a -m "alt commit" 14 | UNSET: GIT_AUTHOR_DATE 15 | UNSET: GIT_AUTHOR_NAME 16 | UNSET: GIT_AUTHOR_EMAIL 17 | UNSET: GIT_COMMITTER_DATE 18 | UNSET: GIT_COMMITTER_NAME 19 | UNSET: GIT_COMMITTER_EMAIL 20 | GIT: checkout main 21 | FILE: foo = apple 22 | GIT: commit -a -m "main commit" 23 | GIT: checkout alt 24 | GIT: rebase main --ignore-date -------------------------------------------------------------------------------- /tests/states/rebase-cd-is-ad: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | GIT: commit -m "common commit" 5 | GIT: checkout -b alt 6 | FILE: foo = mango 7 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 8 | SET: GIT_AUTHOR_NAME = John Author 9 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 10 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 11 | SET: GIT_COMMITTER_NAME = John Committer 12 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 13 | GIT: commit -a -m "alt commit" 14 | UNSET: GIT_AUTHOR_DATE 15 | UNSET: GIT_AUTHOR_NAME 16 | UNSET: GIT_AUTHOR_EMAIL 17 | UNSET: GIT_COMMITTER_DATE 18 | UNSET: GIT_COMMITTER_NAME 19 | UNSET: GIT_COMMITTER_EMAIL 20 | GIT: checkout main 21 | FILE: foo = apple 22 | GIT: commit -a -m "main commit" 23 | GIT: checkout alt 24 | GIT: rebase main --committer-date-is-author-date -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 2.0.1 4 | 5 | - Update listing description to reflect new functionality 6 | 7 | ## 2.0.0 8 | 9 | - **Add support for editing author/committer name/email** 10 | - Unset committer details after rebase edit 11 | - Improve UI styling 12 | - Use "Current" for `rebase -i` presets 13 | - Allow pasting directly from `git log` / `git show` 14 | 15 | ## 1.1.1 16 | 17 | - Reduce bundled extension size 18 | 19 | ## 1.1.0 20 | 21 | - Preserve window context when hidden 22 | - Show UI while loading 23 | 24 | ## 1.0.1 25 | 26 | - Persist set author date when rebasing (interactive and standard) 27 | - Disable timezone input when date input is being synced 28 | - Reflect that `--committer-date-is-author-date` and `--ignore-date` are not overridable 29 | 30 | ## 1.0.0 31 | 32 | - Initial release 33 | -------------------------------------------------------------------------------- /tests/states/rebase-i: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | GIT: commit -m "common commit" 5 | FILE: foo = mango 6 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 7 | SET: GIT_AUTHOR_NAME = John Author 8 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 9 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 10 | SET: GIT_COMMITTER_NAME = John Committer 11 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 12 | GIT: commit -a -m "alt commit" 13 | UNSET: GIT_AUTHOR_DATE 14 | UNSET: GIT_AUTHOR_NAME 15 | UNSET: GIT_AUTHOR_EMAIL 16 | UNSET: GIT_COMMITTER_DATE 17 | UNSET: GIT_COMMITTER_NAME 18 | UNSET: GIT_COMMITTER_EMAIL 19 | FILE: bar = grape 20 | GIT: add bar 21 | GIT: commit -m "alt commit 2" 22 | FILE: baz = orange 23 | GIT: add baz 24 | GIT: commit -m "alt commit 3" 25 | SET: GIT_EDITOR = node ../edit-rebase-todo.mjs 1 edit 26 | GIT: rebase -i --root -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 9 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 10 | "preLaunchTask": "${defaultBuildTask}" 11 | }, 12 | { 13 | "name": "Run with Test Repo", 14 | "type": "extensionHost", 15 | "request": "launch", 16 | "args": [ 17 | "--extensionDevelopmentPath=${workspaceFolder}", 18 | "${workspaceFolder}/tests/test-repo" 19 | ], 20 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 21 | "preLaunchTask": "Prepare State Test" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build and Watch", 6 | "type": "npm", 7 | "script": "watch", 8 | "problemMatcher": "$ts-checker-webpack-watch", 9 | "isBackground": true, 10 | "presentation": { 11 | "reveal": "never" 12 | }, 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | }, 18 | { 19 | "label": "Create Test Repo", 20 | "type": "shell", 21 | "command": "node \"${workspaceFolder}/tests/create-test-repo.mjs\"", 22 | "isBackground": false 23 | }, 24 | { 25 | "label": "Prepare State Test", 26 | "dependsOn": ["Build and Watch", "Create Test Repo"] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /tests/states/rebase: -------------------------------------------------------------------------------- 1 | GIT: init -b main 2 | FILE: foo = bar 3 | GIT: add foo 4 | GIT: commit -m "common commit" 5 | GIT: checkout -b alt 6 | FILE: foo = mango 7 | SET: GIT_AUTHOR_DATE = 2023-01-01 01:00:00 +00:00 8 | SET: GIT_AUTHOR_NAME = John Author 9 | SET: GIT_AUTHOR_EMAIL = john.author@localhost 10 | SET: GIT_COMMITTER_DATE = 2023-01-01 02:00:00 +00:00 11 | SET: GIT_COMMITTER_NAME = John Committer 12 | SET: GIT_COMMITTER_EMAIL = john.committer@localhost 13 | GIT: commit -a -m "alt commit" 14 | UNSET: GIT_AUTHOR_DATE 15 | UNSET: GIT_AUTHOR_NAME 16 | UNSET: GIT_AUTHOR_EMAIL 17 | UNSET: GIT_COMMITTER_DATE 18 | UNSET: GIT_COMMITTER_NAME 19 | UNSET: GIT_COMMITTER_EMAIL 20 | FILE: bar = grape 21 | GIT: add bar 22 | GIT: commit -m "alt commit 2" 23 | FILE: baz = orange 24 | GIT: add baz 25 | GIT: commit -m "alt commit 3" 26 | GIT: checkout main 27 | FILE: foo = apple 28 | GIT: commit -a -m "main commit" 29 | GIT: checkout alt 30 | GIT: rebase main -------------------------------------------------------------------------------- /src/types/messages.d.ts: -------------------------------------------------------------------------------- 1 | export type DetailComponent = { 2 | identity: string; 3 | date: string; 4 | }; 5 | 6 | export type Details = { 7 | author: DetailComponent; 8 | committer: DetailComponent; 9 | }; 10 | 11 | export type CommitDetails = { hash: string } & Details; 12 | 13 | export type StartMessage = { 14 | authorIdent: string; 15 | committerIdent: string; 16 | headDetails?: Details; 17 | rebase?: { 18 | headDetails: Details; 19 | adIsNow: boolean; 20 | cdIsAd: boolean; 21 | rebaseHeadIsHead: boolean; 22 | amend: boolean; 23 | }; 24 | merge?: { 25 | headDetails: Details; 26 | }; 27 | }; 28 | 29 | export type EndMessage = { 30 | type: 'end'; 31 | author: DetailComponent; 32 | committer: DetailComponent; 33 | amend: boolean; 34 | rebaseAmend: boolean; 35 | editRM: boolean; 36 | }; 37 | 38 | export type StartRequestMessage = { 39 | type: 'start-request'; 40 | }; 41 | 42 | export type WebviewMessage = EndMessage | StartRequestMessage; 43 | -------------------------------------------------------------------------------- /src/webview/git-identity-input.ts: -------------------------------------------------------------------------------- 1 | import GitDatumInput from './git-datum-input'; 2 | import { formatIdentity, parseIdentity } from '../util'; 3 | 4 | export default class GitIdentityInput extends GitDatumInput { 5 | private readonly emailInput: HTMLInputElement; 6 | 7 | constructor() { 8 | super(document.createElement('input')); 9 | 10 | this.primaryInput.type = 'text'; 11 | this.primaryInput.placeholder = 'Name'; 12 | this.primaryInput.title = 13 | 'Entire identity string (e.g. Bob Smith ) can be pasted here'; 14 | 15 | this.emailInput = document.createElement('input'); 16 | this.emailInput.type = 'email'; 17 | this.emailInput.placeholder = 'Email'; 18 | this.addInput(this.emailInput); 19 | } 20 | 21 | protected get internalValue() { 22 | return formatIdentity({ 23 | name: this.primaryInput.value, 24 | email: this.emailInput.value, 25 | }); 26 | } 27 | 28 | protected set internalValue(str: string) { 29 | const identity = parseIdentity(str); 30 | this.primaryInput.value = identity.name; 31 | this.emailInput.value = identity.email; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import CopyPlugin from 'copy-webpack-plugin'; 2 | import type { Configuration } from 'webpack'; 3 | 4 | export default [ 5 | { 6 | target: 'node', 7 | entry: { 8 | extension: './src/extension.ts', 9 | }, 10 | output: { 11 | libraryTarget: 'commonjs2', 12 | }, 13 | externals: { 14 | vscode: 'commonjs vscode', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.ts$/, 20 | use: 'ts-loader', 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | extensions: ['.ts'], 26 | }, 27 | }, 28 | { 29 | target: 'web', 30 | entry: { 31 | webview: './src/webview/index.ts', 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.ts$/, 37 | use: 'ts-loader', 38 | }, 39 | ], 40 | }, 41 | resolve: { 42 | extensions: ['.ts'], 43 | }, 44 | plugins: [ 45 | new CopyPlugin({ 46 | patterns: [ 47 | { 48 | from: 'src/webview/index.css', 49 | to: 'webview.css', 50 | }, 51 | ], 52 | }), 53 | ], 54 | }, 55 | ] as Configuration[]; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commit with Date & Identity 2 | 3 | 4 | 5 | **Commit with Date & Identity** is an Visual Studio Code extension that allows you to create and amend Git commits with manually set author and committer dates and identities. 6 | 7 | ## Features 8 | 9 | ### Author and committer support 10 | 11 | Provides the ability to set author details, committer details, or both. Also allows you to synchronize the two with a single click. 12 | 13 | ### Default values 14 | 15 | Significant effort has been made to use the same default dates that Git uses for standard commits, amending, rebasing, and merging. 16 | 17 | ### Preset dates 18 | 19 | Provides convenient buttons to use and modify dates and identities from relevant commits. Always provides a button to select the author/commit dates and identities from `HEAD`. 20 | 21 | ### Merge and rebase support 22 | 23 | Support for continuing during rebases and committing during merges. When rebasing, provides a button to use the dates and identities of the former commit (`REBASE_HEAD`). When merging, provides to use the dates and identities from their commit (`MERGE_HEAD`). 24 | 25 | ## Testing 26 | 27 | The extension repository contains predefined Git repository states that can be used to make sure different features of the extension are working. 28 | 29 | To use one of these states, use the command `npm run test-repo --state=[STATE]` where name is the optional name of the state. If no name is provided, a prompt will be shown. After the repository is created, you will have the opportunity to view the Git log to validate the author and committer details. 30 | -------------------------------------------------------------------------------- /tests/create-test-repo.mjs: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; 2 | import { exec } from 'node:child_process'; 3 | import { join, dirname } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { createInterface } from 'node:readline/promises'; 6 | import { stdin as input, stdout as output } from 'node:process'; 7 | import { emptyDir } from 'fs-extra'; 8 | import { existsSync } from 'node:fs'; 9 | 10 | const lineRegex = /^([A-Z]+): ?(.+?)(?: = (.+)|)$/; 11 | const testDir = dirname(fileURLToPath(import.meta.url)); 12 | 13 | let state; 14 | 15 | if (process.env.npm_config_state) { 16 | state = process.env.npm_config_state; 17 | } else { 18 | const rl = createInterface({ input, output }); 19 | const states = await readdir(join(testDir, 'states')); 20 | 21 | states.forEach((name, i) => { 22 | console.log(`${i + 1}) ${name}`); 23 | }); 24 | 25 | const num = +(await rl.question('\nEnter state number: ')) - 1; 26 | rl.close(); 27 | 28 | state = states[num]; 29 | } 30 | 31 | const contents = await readFile(join(testDir, 'states', state), 'utf-8'); 32 | const lines = contents.split(/\r?\n/); 33 | const folder = join(testDir, 'test-repo'); 34 | 35 | await emptyDir(folder); 36 | 37 | if (!existsSync(folder)) { 38 | await mkdir(folder); 39 | } 40 | 41 | for (let line of lines) { 42 | const match = lineRegex.exec(line); 43 | 44 | if (!match) { 45 | throw new Error('Invalid line: ' + line); 46 | } 47 | 48 | console.log(line); 49 | 50 | const [, command, arg, extra] = match; 51 | 52 | switch (command) { 53 | case 'GIT': 54 | await new Promise((resolve) => { 55 | exec('git ' + arg, { cwd: folder }, (_, out, err) => { 56 | console.log(out); 57 | console.error(err); 58 | resolve(); 59 | }); 60 | }); 61 | break; 62 | case 'FILE': 63 | await writeFile(join(folder, arg), extra); 64 | break; 65 | case 'SET': 66 | process.env[arg] = extra; 67 | break; 68 | case 'UNSET': 69 | delete process.env[arg]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/webview/git-date-input.ts: -------------------------------------------------------------------------------- 1 | import GitDatumInput from './git-datum-input'; 2 | import TimezoneInput from './timezone-input'; 3 | 4 | export default class GitDateInput extends GitDatumInput { 5 | static readonly globalDefault = GitDateInput.now(); 6 | 7 | static now() { 8 | const date = new Date(); 9 | const offset = date.getTimezoneOffset(); 10 | 11 | date.setMinutes(date.getMinutes() - offset); 12 | const dateStr = date.toISOString().slice(0, 19); 13 | 14 | const offsetAbs = Math.abs(offset); 15 | const hours = Math.floor(offsetAbs / 60) 16 | .toString() 17 | .padStart(2, '0'); 18 | const minutes = (offsetAbs % 60).toString().padStart(2, '0'); 19 | 20 | return `${dateStr}${offset > 0 ? '-' : '+'}${hours}:${minutes}`; 21 | } 22 | 23 | private readonly timezoneInput: TimezoneInput; 24 | 25 | constructor() { 26 | super(document.createElement('input')); 27 | 28 | this.primaryInput.type = 'datetime-local'; 29 | this.primaryInput.step = '1'; 30 | this.primaryInput.min = '1970-01-01T00:00'; 31 | this.primaryInput.title = 32 | 'Entire date string (e.g. Wed Jan 1 12:00:00 2025 -0500) can be pasted here'; 33 | 34 | this.timezoneInput = document.createElement( 35 | 'timezone-input', 36 | ) as TimezoneInput; 37 | this.addInput(this.timezoneInput); 38 | 39 | this.value = GitDateInput.globalDefault; 40 | } 41 | 42 | protected get internalValue() { 43 | const extra = this.primaryInput.value.length < 19 ? ':00' : ''; 44 | return this.primaryInput.value + extra + this.timezoneInput.value; 45 | } 46 | 47 | protected set internalValue(str: string) { 48 | if (str.length === 29) { 49 | // Default date format / Wed Jan 1 12:00:00 2025 -0500 50 | const date = new Date(str.substring(0, 24) + ' +0000'); 51 | this.primaryInput.value = date.toISOString().substring(0, 19); 52 | this.timezoneInput.value = 53 | str.substring(24, 27) + ':' + str.substring(27, 29); 54 | return; 55 | } 56 | 57 | // ISO date format / 2025-12-09T19:51:27-05:00 58 | this.primaryInput.value = str.substring(0, 19); 59 | this.timezoneInput.value = str.substring(19); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/webview/timezone-input.ts: -------------------------------------------------------------------------------- 1 | export default class TimezoneInput extends HTMLElement { 2 | private static readonly tzRegex = /([+-])(\d{2}):(\d{2})/; 3 | 4 | private readonly signInput: HTMLSelectElement; 5 | private readonly hourInput: HTMLInputElement; 6 | private readonly minuteInput: HTMLInputElement; 7 | 8 | private readonly subInputChanged = () => { 9 | this.dispatchEvent(new Event('change')); 10 | }; 11 | 12 | private readonly numberInputChanged = (e: Event) => { 13 | let input = e.target as HTMLInputElement; 14 | input.value = input.value.padStart(2, '0'); 15 | 16 | this.subInputChanged(); 17 | }; 18 | 19 | constructor() { 20 | super(); 21 | 22 | const positiveOption = document.createElement('option'); 23 | positiveOption.textContent = '+'; 24 | 25 | const negativeOption = document.createElement('option'); 26 | negativeOption.textContent = '-'; 27 | 28 | this.signInput = document.createElement('select'); 29 | this.signInput.append(positiveOption, negativeOption); 30 | this.signInput.addEventListener('change', this.subInputChanged); 31 | 32 | this.hourInput = document.createElement('input'); 33 | this.hourInput.type = 'number'; 34 | this.hourInput.min = '0'; 35 | this.hourInput.max = '23'; 36 | this.hourInput.addEventListener('change', this.numberInputChanged); 37 | 38 | this.minuteInput = document.createElement('input'); 39 | this.minuteInput.type = 'number'; 40 | this.minuteInput.min = '0'; 41 | this.minuteInput.max = '59'; 42 | this.minuteInput.addEventListener('change', this.numberInputChanged); 43 | } 44 | 45 | connectedCallback() { 46 | this.append(this.signInput, this.hourInput, this.minuteInput); 47 | } 48 | 49 | disconnectedCallback() { 50 | this.innerHTML = ''; 51 | } 52 | 53 | get value() { 54 | return ( 55 | this.signInput.value + 56 | this.hourInput.value + 57 | ':' + 58 | this.minuteInput.value 59 | ); 60 | } 61 | 62 | set value(val: string) { 63 | const match = TimezoneInput.tzRegex.exec(val); 64 | 65 | if (!match) return; 66 | 67 | const [, sign, hourStr, minStr] = match; 68 | 69 | this.signInput.value = sign; 70 | this.hourInput.value = hourStr; 71 | this.minuteInput.value = minStr; 72 | } 73 | 74 | set disabled(disabled: boolean) { 75 | this.signInput.disabled = disabled; 76 | this.hourInput.disabled = disabled; 77 | this.minuteInput.disabled = disabled; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/webview/index.css: -------------------------------------------------------------------------------- 1 | .vscode-dark { 2 | color-scheme: dark; 3 | } 4 | 5 | body { 6 | padding: 20px; 7 | accent-color: var(--vscode-button-background); 8 | } 9 | 10 | h1 { 11 | margin: 0 0 0.5em 0; 12 | line-height: 1; 13 | } 14 | 15 | input, 16 | select { 17 | padding: 4px 6px; 18 | background: var(--vscode-input-background); 19 | color: var(--vscode-input-foreground); 20 | border: 1px solid var(--vscode-badge-background); 21 | border-radius: 2px; 22 | outline-color: var(--vscode-focusBorder); 23 | } 24 | 25 | input[type='checkbox'] { 26 | margin: 0; 27 | } 28 | 29 | button:enabled { 30 | cursor: pointer; 31 | } 32 | 33 | button:disabled, 34 | input:disabled, 35 | select:disabled { 36 | opacity: 0.5; 37 | } 38 | 39 | .checkbox-label { 40 | display: inline-flex; 41 | align-items: center; 42 | gap: 6px; 43 | line-height: 0.5; 44 | } 45 | 46 | .row { 47 | margin-top: 2.5rem; 48 | display: flex; 49 | column-gap: 3rem; 50 | row-gap: 1.5rem; 51 | flex-wrap: wrap; 52 | } 53 | 54 | .git-datum-input { 55 | display: flex; 56 | flex-direction: column; 57 | gap: 0.65rem; 58 | } 59 | 60 | .git-datum-label { 61 | font-weight: 600; 62 | color: var(--vscode-settings-headerForeground); 63 | } 64 | 65 | .container-row { 66 | display: flex; 67 | } 68 | 69 | .input-row, 70 | .wrapped-container { 71 | display: flex; 72 | align-items: center; 73 | flex-wrap: wrap; 74 | column-gap: 0.5rem; 75 | row-gap: 0.5rem; 76 | } 77 | 78 | .wrapped-container { 79 | flex: 1; 80 | width: 0; 81 | } 82 | 83 | .wrapped-loose { 84 | row-gap: 0.65rem; 85 | column-gap: 1rem; 86 | } 87 | 88 | .forced-alternative { 89 | outline: 1px solid var(--vscode-focusBorder); 90 | border-radius: 4px; 91 | padding: 6px; 92 | line-height: 1.5; 93 | width: 100%; 94 | } 95 | 96 | .preset-btn { 97 | border: 1px solid var(--vscode-badge-background); 98 | color: var(--vscode-badge-foreground); 99 | border-radius: 6px; 100 | padding: 2px 4px; 101 | background: none; 102 | } 103 | 104 | timezone-input { 105 | display: flex; 106 | gap: 0.5em; 107 | } 108 | 109 | timezone-input input[type='number'] { 110 | width: 2.5em; 111 | } 112 | 113 | #actions { 114 | margin-top: 2.5rem; 115 | } 116 | 117 | #submit { 118 | padding: 4px 12px; 119 | border: none; 120 | border-radius: 2px; 121 | background-color: var(--vscode-button-background); 122 | color: var(--vscode-button-foreground); 123 | line-height: 18px; 124 | } 125 | 126 | #submit:enabled:hover, 127 | #submit:enabled:focus { 128 | background-color: var(--vscode-button-hoverBackground); 129 | } 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commit-with-date", 3 | "displayName": "Commit with Date & Identity", 4 | "description": "Create and amend Git commits with manually set author and committer dates and identities.", 5 | "publisher": "brandonfowler", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/BrandonXLF/commit-with-date" 10 | }, 11 | "homepage": "https://github.com/BrandonXLF/commit-with-date", 12 | "bugs": { 13 | "url": "https://github.com/BrandonXLF/commit-with-date/issues" 14 | }, 15 | "icon": "media/icon.png", 16 | "version": "2.0.1", 17 | "engines": { 18 | "vscode": "^1.60.0" 19 | }, 20 | "categories": [ 21 | "SCM Providers", 22 | "Other" 23 | ], 24 | "extensionKind": [ 25 | "workspace" 26 | ], 27 | "extensionDependencies": [ 28 | "vscode.git" 29 | ], 30 | "activationEvents": [], 31 | "main": "./dist/extension.js", 32 | "contributes": { 33 | "commands": [ 34 | { 35 | "category": "Commit with Date & Identity", 36 | "command": "commitWithDate.commitWithDate", 37 | "title": "Commit with Date & Identity", 38 | "enablement": "!operationInProgress", 39 | "icon": { 40 | "light": "media/light/icon.svg", 41 | "dark": "media/dark/icon.svg" 42 | } 43 | } 44 | ], 45 | "menus": { 46 | "commandPalette": [ 47 | { 48 | "command": "commitWithDate.commitWithDate", 49 | "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0" 50 | } 51 | ], 52 | "git.commit": [ 53 | { 54 | "command": "commitWithDate.commitWithDate" 55 | } 56 | ], 57 | "scm/title": [ 58 | { 59 | "when": "scmProvider == git", 60 | "command": "commitWithDate.commitWithDate", 61 | "group": "navigation" 62 | } 63 | ] 64 | } 65 | }, 66 | "scripts": { 67 | "compile": "webpack --mode production", 68 | "lint": "eslint src --ext ts && prettier --write --tab-width 4 --single-quote .", 69 | "vscode:prepublish": "npm run compile", 70 | "watch": "webpack --watch --mode development", 71 | "test-repo": "npm run test-repo:create && node ./tests/prompt-continue.mjs && npm run test-repo:log", 72 | "test-repo:create": "node ./tests/create-test-repo.mjs", 73 | "test-repo:log": "cd ./tests/test-repo && git log --pretty=format:\"Author: %an %ae %ad%nCommitter: %cn %ce %cd %n\"" 74 | }, 75 | "devDependencies": { 76 | "@types/node": "14.14.9", 77 | "@types/vscode": "1.60.0", 78 | "@types/vscode-webview": "1.57.4", 79 | "@typescript-eslint/eslint-plugin": "^6.16.0", 80 | "@typescript-eslint/parser": "^6.16.0", 81 | "copy-webpack-plugin": "^11.0.0", 82 | "eslint": "^8.56.0", 83 | "fs-extra": "^11.3.2", 84 | "prettier": "^3.1.1", 85 | "ts-loader": "^9.5.1", 86 | "ts-node": "^10.9.2", 87 | "typescript": "^5.3.3", 88 | "webpack-cli": "^5.1.4" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/webview/presets.ts: -------------------------------------------------------------------------------- 1 | import { DetailComponent, StartMessage } from '../types/messages'; 2 | import GitDateInput from './git-date-input'; 3 | import { Preset } from './git-datum-input'; 4 | 5 | function addHeadPresets( 6 | presets: Preset[], 7 | data: StartMessage, 8 | amend: boolean, 9 | prop: keyof DetailComponent, 10 | sharedName: string, 11 | authorName: string, 12 | committerName: string, 13 | ) { 14 | if (data.rebase && !data.rebase.rebaseHeadIsHead) { 15 | if ( 16 | data.rebase.headDetails.author[prop] === 17 | data.rebase.headDetails.committer[prop] 18 | ) { 19 | presets.push({ 20 | label: `Former ${sharedName}`, 21 | tooltip: `${authorName} and ${committerName} for the commit being applied to HEAD`, 22 | value: data.rebase.headDetails.author[prop], 23 | }); 24 | } else { 25 | presets.push( 26 | { 27 | label: `Former ${authorName}`, 28 | tooltip: `${authorName} for the commit being applied to HEAD`, 29 | value: data.rebase.headDetails.author[prop], 30 | }, 31 | { 32 | label: `Former ${committerName}`, 33 | tooltip: `${committerName} for the commit being applied to HEAD`, 34 | value: data.rebase.headDetails.committer[prop], 35 | }, 36 | ); 37 | } 38 | } 39 | 40 | const headDateLabel = data.merge 41 | ? 'Our' 42 | : amend || data.rebase?.amend 43 | ? 'Current' 44 | : 'HEAD'; 45 | 46 | if (data.headDetails) { 47 | if ( 48 | data.headDetails.author[prop] === data.headDetails.committer[prop] 49 | ) { 50 | presets.push({ 51 | label: `${headDateLabel} ${sharedName}`, 52 | tooltip: `${headDateLabel} ${authorName} and ${committerName}`, 53 | value: data.headDetails.author[prop], 54 | }); 55 | } else { 56 | presets.push( 57 | { 58 | label: `${headDateLabel} ${authorName}`, 59 | value: data.headDetails.author[prop], 60 | }, 61 | { 62 | label: `${headDateLabel} ${committerName}`, 63 | value: data.headDetails.committer[prop], 64 | }, 65 | ); 66 | } 67 | } 68 | 69 | if (data.merge) { 70 | if ( 71 | data.merge.headDetails.author[prop] === 72 | data.merge.headDetails.committer[prop] 73 | ) { 74 | presets.push({ 75 | label: `Their ${sharedName}`, 76 | tooltip: `Their ${authorName} and ${committerName}`, 77 | value: data.merge.headDetails.author[prop], 78 | }); 79 | } else { 80 | presets.push( 81 | { 82 | label: `Their ${authorName}`, 83 | value: data.merge.headDetails.author[prop], 84 | }, 85 | { 86 | label: `Their ${committerName}`, 87 | value: data.merge.headDetails.committer[prop], 88 | }, 89 | ); 90 | } 91 | } 92 | 93 | return presets; 94 | } 95 | 96 | export default function getDatePresets(data: StartMessage, amend: boolean) { 97 | let presets: Preset[] = []; 98 | 99 | presets.push({ 100 | label: 'Now', 101 | value: () => GitDateInput.now(), 102 | }); 103 | 104 | return addHeadPresets( 105 | presets, 106 | data, 107 | amend, 108 | 'date', 109 | 'Date', 110 | 'Author Date', 111 | 'Commit Date', 112 | ); 113 | } 114 | 115 | export function getIdentityPresets(data: StartMessage, amend: boolean) { 116 | let presets: Preset[] = []; 117 | 118 | if (data.authorIdent === data.committerIdent) { 119 | presets.push({ 120 | label: 'You', 121 | value: data.authorIdent, 122 | }); 123 | } else { 124 | presets.push( 125 | { 126 | label: 'You (Author)', 127 | value: data.authorIdent, 128 | }, 129 | { 130 | label: 'You (Committer)', 131 | value: data.committerIdent, 132 | }, 133 | ); 134 | } 135 | 136 | return addHeadPresets( 137 | presets, 138 | data, 139 | amend, 140 | 'identity', 141 | 'Identity', 142 | 'Author', 143 | 'Committer', 144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/webview/index.ts: -------------------------------------------------------------------------------- 1 | import GitDateInput from './git-date-input'; 2 | import TimezoneInput from './timezone-input'; 3 | import { 4 | StartMessage, 5 | EndMessage, 6 | StartRequestMessage, 7 | } from '../types/messages'; 8 | import getDatePresets, { getIdentityPresets } from './presets'; 9 | import GitIdentityInput from './git-identity-input'; 10 | 11 | customElements.define('timezone-input', TimezoneInput); 12 | customElements.define('git-date-input', GitDateInput); 13 | customElements.define('git-identity-input', GitIdentityInput); 14 | 15 | const vscode = acquireVsCodeApi(); 16 | 17 | let amendCheck = document.getElementById('amend') as HTMLInputElement; 18 | let submitButton = document.getElementById('submit') as HTMLButtonElement; 19 | let authorDateInput = document.getElementById('author-date') as GitDateInput; 20 | let commitDateInput = document.getElementById('commit-date') as GitDateInput; 21 | let authorIdentityInput = document.getElementById( 22 | 'author-identity', 23 | ) as GitIdentityInput; 24 | let committerIdentityInput = document.getElementById( 25 | 'committer-identity', 26 | ) as GitIdentityInput; 27 | 28 | function loadPresets(data: StartMessage, amend = false) { 29 | const datePresets = getDatePresets(data, amend); 30 | authorDateInput.presets = datePresets; 31 | commitDateInput.presets = datePresets; 32 | 33 | const identityPresets = getIdentityPresets(data, amend); 34 | authorIdentityInput.presets = identityPresets; 35 | committerIdentityInput.presets = identityPresets; 36 | } 37 | 38 | window.addEventListener('message', (e: MessageEvent) => { 39 | let data = e.data; 40 | 41 | amendCheck.disabled = false; 42 | submitButton.disabled = false; 43 | authorDateInput.disabled = false; 44 | authorIdentityInput.disabled = false; 45 | commitDateInput.disabled = false; 46 | committerIdentityInput.disabled = false; 47 | 48 | authorIdentityInput.forcedValue = data.authorIdent; 49 | committerIdentityInput.forcedValue = data.committerIdent; 50 | 51 | if (data.rebase?.adIsNow) { 52 | const reasonSpan = document.createElement('span'); 53 | reasonSpan.innerHTML = '--ignore-date is being used.'; 54 | authorDateInput.alternativeForced = reasonSpan; 55 | 56 | const nowDateInput = new GitDateInput(); 57 | nowDateInput.label = 'Commit Time'; 58 | authorDateInput.alternative = nowDateInput; 59 | 60 | setInterval(() => { 61 | nowDateInput.forcedValue = GitDateInput.now(); 62 | }, 500); 63 | } 64 | 65 | if (data.rebase?.cdIsAd) { 66 | const reasonSpan = document.createElement('span'); 67 | reasonSpan.innerHTML = 68 | '--committer-date-is-author-date is being used.'; 69 | commitDateInput.alternativeForced = reasonSpan; 70 | } 71 | 72 | commitDateInput.alternative = authorDateInput; 73 | committerIdentityInput.alternative = authorIdentityInput; 74 | 75 | submitButton.addEventListener('click', () => { 76 | vscode.postMessage({ 77 | author: { 78 | identity: authorIdentityInput.value, 79 | date: authorDateInput.value, 80 | }, 81 | committer: { 82 | identity: committerIdentityInput.value, 83 | date: commitDateInput.value, 84 | }, 85 | amend: amendCheck.checked, 86 | rebaseAmend: data.rebase?.amend ?? false, 87 | editRM: data.rebase !== undefined, 88 | } as EndMessage); 89 | }); 90 | 91 | if (data.rebase || !data.headDetails) { 92 | amendCheck.closest('div')!.style.display = 'none'; 93 | } else { 94 | amendCheck.addEventListener('change', () => { 95 | if (!data?.headDetails) return; 96 | 97 | let amend = amendCheck.checked; 98 | 99 | authorDateInput.forcedValue = amend 100 | ? data.headDetails.author.date 101 | : GitDateInput.globalDefault; 102 | authorIdentityInput.forcedValue = amend 103 | ? data.headDetails.author.identity 104 | : data.authorIdent; 105 | 106 | // Reset committer details as well 107 | commitDateInput.forcedValue = GitDateInput.globalDefault; 108 | committerIdentityInput.forcedValue = data.committerIdent; 109 | 110 | loadPresets(data, amend); 111 | submitButton.textContent = amend ? 'Amend' : 'Commit'; 112 | }); 113 | } 114 | 115 | if (data.rebase) { 116 | authorDateInput.forcedValue = data.rebase.adIsNow 117 | ? GitDateInput.globalDefault 118 | : data.rebase.headDetails.author.date; 119 | authorIdentityInput.forcedValue = 120 | data.rebase.headDetails.author.identity; 121 | 122 | commitDateInput.forcedValue = data.rebase.cdIsAd 123 | ? data.rebase.headDetails.author.date 124 | : GitDateInput.globalDefault; 125 | committerIdentityInput.forcedValue = 126 | data.rebase.headDetails.committer.identity; 127 | 128 | submitButton.textContent = 'Continue'; 129 | } 130 | 131 | loadPresets(data); 132 | submitButton.textContent = 'Commit'; 133 | }); 134 | 135 | vscode.postMessage({ 136 | type: 'start-request', 137 | } as StartRequestMessage); 138 | -------------------------------------------------------------------------------- /src/webview/git-datum-input.ts: -------------------------------------------------------------------------------- 1 | export interface Preset { 2 | label: string; 3 | tooltip?: string; 4 | value: string | (() => string); 5 | } 6 | 7 | export default abstract class GitDatumInput< 8 | PrimaryInputType extends HTMLElement & { disabled: boolean }, 9 | > extends HTMLElement { 10 | static readonly observedAttributes = ['label', 'disabled']; 11 | 12 | protected readonly alternativeCnt: HTMLDivElement; 13 | protected readonly labelEl: HTMLLabelElement; 14 | 15 | protected readonly aboveRow: HTMLDivElement; 16 | protected readonly aboveContainer: HTMLDivElement; 17 | protected readonly inputRow: HTMLDivElement; 18 | protected readonly presetRow: HTMLDivElement; 19 | protected readonly presetContainer: HTMLDivElement; 20 | 21 | private alternativeInput?: 22 | | HTMLInputElement 23 | | GitDatumInput; 24 | private alternativeCheck?: HTMLInputElement; 25 | private alternativeForcedReason?: HTMLElement; 26 | 27 | protected inputs: { disabled: boolean }[] = []; 28 | 29 | protected subInputChanged = () => { 30 | this.dispatchEvent(new Event('change')); 31 | }; 32 | 33 | protected primaryFieldPaste = (e: ClipboardEvent) => { 34 | const pasteData = e.clipboardData?.getData('text'); 35 | if (!pasteData) return; 36 | 37 | try { 38 | this.value = pasteData; 39 | e.preventDefault(); 40 | } catch {} 41 | }; 42 | 43 | constructor(protected readonly primaryInput: PrimaryInputType) { 44 | super(); 45 | 46 | this.primaryInput.id ??= 47 | 'primary-input-' + Math.random().toString(36).substring(2, 15); 48 | 49 | this.labelEl = document.createElement('label'); 50 | this.labelEl.classList.add('git-datum-label'); 51 | this.labelEl.htmlFor = this.primaryInput.id; 52 | 53 | this.alternativeCnt = document.createElement('div'); 54 | 55 | this.aboveRow = document.createElement('div'); 56 | this.aboveRow.classList.add('container-row'); 57 | this.aboveContainer = document.createElement('div'); 58 | this.aboveContainer.classList.add('wrapped-container', 'wrapped-loose'); 59 | this.aboveRow.append(this.aboveContainer); 60 | this.aboveContainer.append(this.labelEl, this.alternativeCnt); 61 | 62 | this.inputRow = document.createElement('div'); 63 | this.inputRow.classList.add('input-row'); 64 | this.primaryInput.addEventListener('paste', this.primaryFieldPaste); 65 | this.addInput(this.primaryInput); 66 | 67 | this.presetRow = document.createElement('div'); 68 | this.presetRow.classList.add('container-row'); 69 | this.presetContainer = document.createElement('div'); 70 | this.presetContainer.classList.add('wrapped-container'); 71 | this.presetRow.append(this.presetContainer); 72 | 73 | this.classList.add('git-datum-input'); 74 | } 75 | 76 | protected addInput(input: HTMLElement & { disabled: boolean }) { 77 | input.addEventListener('change', this.subInputChanged); 78 | this.inputRow.append(input); 79 | this.inputs.push(input); 80 | } 81 | 82 | connectedCallback() { 83 | this.append(this.aboveRow, this.inputRow, this.presetRow); 84 | } 85 | 86 | disconnectedCallback() { 87 | this.innerHTML = ''; 88 | } 89 | 90 | attributeChangedCallback(name: string, _: string, newValue: string | null) { 91 | switch (name) { 92 | case 'label': 93 | this.label = newValue ?? ''; 94 | break; 95 | case 'disabled': 96 | this.disabled = newValue !== null; 97 | } 98 | } 99 | 100 | syncAlternative() { 101 | this.disabled = this.alternativeCheck!.checked; 102 | 103 | if (this.alternativeCheck!.checked) { 104 | this.value = this.alternativeInput!.value; 105 | } 106 | } 107 | 108 | protected abstract get internalValue(); 109 | protected abstract set internalValue(value: string); 110 | 111 | get value() { 112 | return this.internalValue; 113 | } 114 | 115 | protected set value(str: string) { 116 | this.internalValue = str; 117 | this.dispatchEvent(new Event('change')); 118 | } 119 | 120 | get label() { 121 | return this.labelEl.textContent ?? ''; 122 | } 123 | 124 | set label(label: string) { 125 | this.labelEl.textContent = label; 126 | } 127 | 128 | /** 129 | * Should only be used before {@link alternative} is set 130 | */ 131 | set disabled(disabled: boolean) { 132 | for (const input of this.inputs) { 133 | input.disabled = disabled; 134 | } 135 | } 136 | 137 | set alternativeForced(reason: HTMLElement | undefined) { 138 | if (this.alternativeInput) { 139 | throw new Error('Must be set before "alternative" is set.'); 140 | } 141 | 142 | this.alternativeForcedReason = reason; 143 | } 144 | 145 | set alternative( 146 | alternative: GitDatumInput, 147 | ) { 148 | if (this.alternativeInput) { 149 | throw new Error('"alternative" can only be set once.'); 150 | } 151 | 152 | this.alternativeInput = alternative; 153 | 154 | this.alternativeCheck = document.createElement('input'); 155 | this.alternativeCheck.type = 'checkbox'; 156 | this.alternativeCheck.checked = true; 157 | 158 | this.alternativeInput.addEventListener('change', () => 159 | this.syncAlternative(), 160 | ); 161 | 162 | this.alternativeCheck.addEventListener('change', () => 163 | this.syncAlternative(), 164 | ); 165 | 166 | this.syncAlternative(); 167 | 168 | if (this.alternativeForcedReason) { 169 | this.alternativeCnt.replaceChildren( 170 | `Using ${this.alternativeInput.label} since `, 171 | this.alternativeForcedReason, 172 | ); 173 | this.alternativeCnt.classList.add('forced-alternative'); 174 | } else { 175 | const labelEl = document.createElement('label'); 176 | labelEl.className = 'checkbox-label'; 177 | labelEl.append( 178 | this.alternativeCheck, 179 | `Use ${this.alternativeInput.label}`, 180 | ); 181 | this.alternativeCnt.replaceChildren(labelEl); 182 | } 183 | } 184 | 185 | set forcedValue(str: string) { 186 | this.value = str; 187 | 188 | if (this.alternativeInput && !this.alternativeForcedReason) { 189 | this.alternativeCheck!.checked = 190 | this.value === this.alternativeInput.value; 191 | this.syncAlternative(); 192 | } 193 | } 194 | 195 | set presets(presets: Preset[]) { 196 | this.presetContainer.innerHTML = ''; 197 | 198 | for (let preset of presets) { 199 | const btn = document.createElement('button'); 200 | btn.className = 'preset-btn'; 201 | btn.textContent = preset.label; 202 | btn.disabled = this.alternativeCheck?.checked ?? false; 203 | 204 | if (preset.tooltip) { 205 | btn.title = preset.tooltip; 206 | } 207 | 208 | btn.addEventListener('click', () => { 209 | this.value = 210 | typeof preset.value === 'function' 211 | ? preset.value() 212 | : preset.value; 213 | }); 214 | 215 | this.presetContainer.append(btn); 216 | this.inputs.push(btn); 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as git from './types/git'; 3 | import { execFile } from 'child_process'; 4 | import * as fs from 'fs'; 5 | import { 6 | CommitDetails, 7 | DetailComponent, 8 | EndMessage, 9 | StartMessage, 10 | WebviewMessage, 11 | } from './types/messages'; 12 | import path from 'path'; 13 | import { Identity, parseIdentity } from './util'; 14 | 15 | class Committer { 16 | private readonly envVariables: string[] = []; 17 | 18 | constructor( 19 | private readonly ctx: vscode.ExtensionContext, 20 | private readonly git: string, 21 | private readonly repo: git.Repository, 22 | ) {} 23 | 24 | private execRepoCmd(...args: string[]) { 25 | return new Promise((resolve) => { 26 | execFile( 27 | this.git, 28 | args, 29 | { 30 | cwd: this.repo.rootUri.fsPath, 31 | }, 32 | (_, stdout) => resolve(stdout), 33 | ); 34 | }); 35 | } 36 | 37 | private async getDetails(hash: string): Promise { 38 | const out = await this.execRepoCmd( 39 | 'show', 40 | '-s', 41 | '--format=%H%n%an <%ae>%n%aI%n%cn <%ce>%n%cI', 42 | hash, 43 | ); 44 | 45 | if (!out) return; 46 | 47 | const parts = out.trim().split('\n'); 48 | 49 | return { 50 | hash: parts[0], 51 | author: { 52 | identity: parts[1], 53 | date: parts[2], 54 | }, 55 | committer: { 56 | identity: parts[3], 57 | date: parts[4], 58 | }, 59 | }; 60 | } 61 | 62 | private async getRMFileUrl(file: string) { 63 | return vscode.Uri.joinPath( 64 | this.repo.rootUri, 65 | '.git', 66 | 'rebase-merge', 67 | file, 68 | ); 69 | } 70 | 71 | private async getRMFlag(flag: string) { 72 | const flagUri = await this.getRMFileUrl(flag); 73 | 74 | try { 75 | await fs.promises.access(flagUri.fsPath, fs.constants.F_OK); 76 | return true; 77 | } catch { 78 | return false; 79 | } 80 | } 81 | 82 | private async pauseRM() { 83 | const todoUri = await this.getRMFileUrl('git-rebase-todo'); 84 | const oldTodo = await fs.promises.readFile(todoUri.fsPath, 'utf-8'); 85 | const newTodo = `break\n${oldTodo}`; 86 | await fs.promises.writeFile(todoUri.fsPath, newTodo); 87 | } 88 | 89 | private async getIdentityString(identity: string): Promise { 90 | const out = await this.execRepoCmd('var', identity); 91 | return out.trim(); 92 | } 93 | 94 | private async getStartMessage() { 95 | const message: StartMessage = { 96 | authorIdent: await this.getIdentityString('GIT_AUTHOR_IDENT'), 97 | committerIdent: await this.getIdentityString('GIT_COMMITTER_IDENT'), 98 | }; 99 | 100 | const headDetails = await this.getDetails('HEAD'); 101 | if (headDetails) { 102 | message.headDetails = headDetails; 103 | } 104 | 105 | const rebaseDetails = await this.getDetails('REBASE_HEAD'); 106 | if (rebaseDetails) { 107 | message.rebase = { 108 | headDetails: rebaseDetails, 109 | rebaseHeadIsHead: headDetails?.hash === rebaseDetails.hash, 110 | adIsNow: await this.getRMFlag('ignore_date'), 111 | cdIsAd: await this.getRMFlag('cdate_is_adate'), 112 | amend: await this.getRMFlag('amend'), 113 | }; 114 | } 115 | 116 | const mergeDetails = await this.getDetails('MERGE_HEAD'); 117 | if (mergeDetails) { 118 | message.merge = { 119 | headDetails: mergeDetails, 120 | }; 121 | } 122 | 123 | return message; 124 | } 125 | 126 | private setEnvVariable(name: string, value: string) { 127 | process.env[name] = value; 128 | this.envVariables.push(name); 129 | } 130 | 131 | private async updateCommitAuthorDetails( 132 | data: EndMessage, 133 | author: DetailComponent, 134 | authorIdentity: Identity, 135 | ) { 136 | if (data.amend || data.rebaseAmend) { 137 | await this.execRepoCmd( 138 | 'commit', 139 | '-o', 140 | '--no-edit', 141 | '--amend', 142 | `--date=${author.date}`, 143 | `--author=${author.identity}`, 144 | ); 145 | } 146 | 147 | if (data.editRM) { 148 | try { 149 | const authorScriptURI = 150 | await this.getRMFileUrl('author-script'); 151 | const oldScript = await fs.promises.readFile( 152 | authorScriptURI.fsPath, 153 | 'utf-8', 154 | ); 155 | 156 | const newScript = oldScript 157 | .replace( 158 | /^GIT_AUTHOR_DATE.+/m, 159 | "GIT_AUTHOR_DATE='" + author.date + "'", 160 | ) 161 | .replace( 162 | /^GIT_AUTHOR_NAME.+/m, 163 | "GIT_AUTHOR_NAME='" + authorIdentity.name + "'", 164 | ) 165 | .replace( 166 | /^GIT_AUTHOR_EMAIL.+/m, 167 | "GIT_AUTHOR_EMAIL='" + authorIdentity.email + "'", 168 | ); 169 | 170 | await fs.promises.writeFile(authorScriptURI.fsPath, newScript); 171 | } catch { 172 | // Pass 173 | } 174 | } 175 | } 176 | 177 | async commit() { 178 | const panel = vscode.window.createWebviewPanel( 179 | 'commitWithDate', 180 | 'Commit with Date & Identity', 181 | vscode.ViewColumn.Active, 182 | { 183 | enableScripts: true, 184 | retainContextWhenHidden: true, 185 | }, 186 | ); 187 | 188 | panel.iconPath = { 189 | light: vscode.Uri.joinPath( 190 | this.ctx.extensionUri, 191 | 'media', 192 | 'light', 193 | 'icon.svg', 194 | ), 195 | dark: vscode.Uri.joinPath( 196 | this.ctx.extensionUri, 197 | 'media', 198 | 'dark', 199 | 'icon.svg', 200 | ), 201 | }; 202 | 203 | const styleURI = panel.webview.asWebviewUri( 204 | vscode.Uri.joinPath(this.ctx.extensionUri, 'dist', 'webview.css'), 205 | ); 206 | 207 | const scriptURI = panel.webview.asWebviewUri( 208 | vscode.Uri.joinPath(this.ctx.extensionUri, 'dist', 'webview.js'), 209 | ); 210 | 211 | panel.webview.html = ` 212 | 213 | 214 | 215 | 216 | 217 | 218 |

Commit with Date & Identity

219 |
220 | 224 |
225 |
226 | 227 | 228 |
229 |
230 | 231 | 232 |
233 |
234 | 235 |
236 | 237 | 238 | 239 | `; 240 | 241 | let data = await new Promise((resolve, reject) => { 242 | panel.webview.onDidReceiveMessage(async (msg: WebviewMessage) => { 243 | if (msg.type === 'start-request') { 244 | panel.webview.postMessage(await this.getStartMessage()); 245 | return; 246 | } 247 | 248 | resolve(msg); 249 | }); 250 | 251 | panel.onDidDispose(() => reject()); 252 | }); 253 | 254 | panel.dispose(); 255 | 256 | const authorIdentity = parseIdentity(data.author.identity); 257 | const committerIdentity = parseIdentity(data.committer.identity); 258 | 259 | this.setEnvVariable('GIT_AUTHOR_DATE', data.author.date); 260 | this.setEnvVariable('GIT_AUTHOR_NAME', authorIdentity.name); 261 | this.setEnvVariable('GIT_AUTHOR_EMAIL', authorIdentity.email); 262 | this.setEnvVariable('GIT_COMMITTER_DATE', data.committer.date); 263 | this.setEnvVariable('GIT_COMMITTER_NAME', committerIdentity.name); 264 | this.setEnvVariable('GIT_COMMITTER_EMAIL', committerIdentity.email); 265 | 266 | await this.updateCommitAuthorDetails(data, data.author, authorIdentity); 267 | 268 | if (data.editRM) { 269 | // Pause to allow run without environment variables interfering 270 | await this.pauseRM(); 271 | } 272 | 273 | await vscode.commands.executeCommand( 274 | data.amend ? 'git.commitAmend' : 'git.commit', 275 | this.repo, 276 | ); 277 | 278 | for (const cleanup of this.envVariables) { 279 | delete process.env[cleanup]; 280 | } 281 | 282 | if (data.editRM) { 283 | await this.execRepoCmd('rebase', '--continue'); // Resume from pause 284 | } 285 | } 286 | } 287 | 288 | async function commitWithDate( 289 | arg: any, 290 | git: git.API, 291 | ctx: vscode.ExtensionContext, 292 | ) { 293 | let repo: git.Repository | null | undefined = git.getRepository(arg); 294 | 295 | if (!repo && git.repositories.length === 1) { 296 | repo = git.repositories[0]; 297 | } 298 | 299 | if (!repo && git.repositories.length > 1) { 300 | repo = ( 301 | await vscode.window.showQuickPick( 302 | git.repositories.map((repo) => ({ 303 | repo, 304 | label: path.basename(repo.rootUri.fsPath), 305 | })), 306 | { 307 | placeHolder: 'Choose a repository', 308 | }, 309 | ) 310 | )?.repo; 311 | } 312 | 313 | if (!repo) { 314 | vscode.window.showErrorMessage('Could not find git repository.'); 315 | return; 316 | } 317 | 318 | await vscode.window.withProgress( 319 | { 320 | location: vscode.ProgressLocation.SourceControl, 321 | }, 322 | () => new Committer(ctx, git.git.path, repo!).commit(), 323 | ); 324 | } 325 | 326 | export async function activate(ctx: vscode.ExtensionContext) { 327 | const gitExtension = 328 | vscode.extensions.getExtension('vscode.git'); 329 | 330 | await gitExtension!.activate(); 331 | 332 | const git = gitExtension!.exports.getAPI(1); 333 | 334 | ctx.subscriptions.push( 335 | vscode.commands.registerCommand( 336 | 'commitWithDate.commitWithDate', 337 | (arg: any) => void commitWithDate(arg, git, ctx), 338 | ), 339 | ); 340 | } 341 | -------------------------------------------------------------------------------- /src/types/git.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { 7 | Uri, 8 | Event, 9 | Disposable, 10 | ProviderResult, 11 | Command, 12 | CancellationToken, 13 | } from 'vscode'; 14 | export { ProviderResult } from 'vscode'; 15 | 16 | export interface Git { 17 | readonly path: string; 18 | } 19 | 20 | export interface InputBox { 21 | value: string; 22 | } 23 | 24 | export const enum ForcePushMode { 25 | Force, 26 | ForceWithLease, 27 | ForceWithLeaseIfIncludes, 28 | } 29 | 30 | export const enum RefType { 31 | Head, 32 | RemoteHead, 33 | Tag, 34 | } 35 | 36 | export interface Ref { 37 | readonly type: RefType; 38 | readonly name?: string; 39 | readonly commit?: string; 40 | readonly remote?: string; 41 | } 42 | 43 | export interface UpstreamRef { 44 | readonly remote: string; 45 | readonly name: string; 46 | readonly commit?: string; 47 | } 48 | 49 | export interface Branch extends Ref { 50 | readonly upstream?: UpstreamRef; 51 | readonly ahead?: number; 52 | readonly behind?: number; 53 | } 54 | 55 | export interface CommitShortStat { 56 | readonly files: number; 57 | readonly insertions: number; 58 | readonly deletions: number; 59 | } 60 | 61 | export interface Commit { 62 | readonly hash: string; 63 | readonly message: string; 64 | readonly parents: string[]; 65 | readonly authorDate?: Date; 66 | readonly authorName?: string; 67 | readonly authorEmail?: string; 68 | readonly commitDate?: Date; 69 | readonly shortStat?: CommitShortStat; 70 | } 71 | 72 | export interface Submodule { 73 | readonly name: string; 74 | readonly path: string; 75 | readonly url: string; 76 | } 77 | 78 | export interface Remote { 79 | readonly name: string; 80 | readonly fetchUrl?: string; 81 | readonly pushUrl?: string; 82 | readonly isReadOnly: boolean; 83 | } 84 | 85 | export const enum Status { 86 | INDEX_MODIFIED, 87 | INDEX_ADDED, 88 | INDEX_DELETED, 89 | INDEX_RENAMED, 90 | INDEX_COPIED, 91 | 92 | MODIFIED, 93 | DELETED, 94 | UNTRACKED, 95 | IGNORED, 96 | INTENT_TO_ADD, 97 | INTENT_TO_RENAME, 98 | TYPE_CHANGED, 99 | 100 | ADDED_BY_US, 101 | ADDED_BY_THEM, 102 | DELETED_BY_US, 103 | DELETED_BY_THEM, 104 | BOTH_ADDED, 105 | BOTH_DELETED, 106 | BOTH_MODIFIED, 107 | } 108 | 109 | export interface Change { 110 | /** 111 | * Returns either `originalUri` or `renameUri`, depending 112 | * on whether this change is a rename change. When 113 | * in doubt always use `uri` over the other two alternatives. 114 | */ 115 | readonly uri: Uri; 116 | readonly originalUri: Uri; 117 | readonly renameUri: Uri | undefined; 118 | readonly status: Status; 119 | } 120 | 121 | export interface RepositoryState { 122 | readonly HEAD: Branch | undefined; 123 | readonly refs: Ref[]; 124 | readonly remotes: Remote[]; 125 | readonly submodules: Submodule[]; 126 | readonly rebaseCommit: Commit | undefined; 127 | 128 | readonly mergeChanges: Change[]; 129 | readonly indexChanges: Change[]; 130 | readonly workingTreeChanges: Change[]; 131 | 132 | readonly onDidChange: Event; 133 | } 134 | 135 | export interface RepositoryUIState { 136 | readonly selected: boolean; 137 | readonly onDidChange: Event; 138 | } 139 | 140 | /** 141 | * Log options. 142 | */ 143 | export interface LogOptions { 144 | /** Max number of log entries to retrieve. If not specified, the default is 32. */ 145 | readonly maxEntries?: number; 146 | readonly path?: string; 147 | /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ 148 | readonly range?: string; 149 | readonly reverse?: boolean; 150 | readonly sortByAuthorDate?: boolean; 151 | readonly shortStats?: boolean; 152 | } 153 | 154 | export interface CommitOptions { 155 | all?: boolean | 'tracked'; 156 | amend?: boolean; 157 | signoff?: boolean; 158 | signCommit?: boolean; 159 | empty?: boolean; 160 | noVerify?: boolean; 161 | requireUserConfig?: boolean; 162 | useEditor?: boolean; 163 | verbose?: boolean; 164 | /** 165 | * string - execute the specified command after the commit operation 166 | * undefined - execute the command specified in git.postCommitCommand 167 | * after the commit operation 168 | * null - do not execute any command after the commit operation 169 | */ 170 | postCommitCommand?: string | null; 171 | } 172 | 173 | export interface FetchOptions { 174 | remote?: string; 175 | ref?: string; 176 | all?: boolean; 177 | prune?: boolean; 178 | depth?: number; 179 | } 180 | 181 | export interface InitOptions { 182 | defaultBranch?: string; 183 | } 184 | 185 | export interface RefQuery { 186 | readonly contains?: string; 187 | readonly count?: number; 188 | readonly pattern?: string; 189 | readonly sort?: 'alphabetically' | 'committerdate'; 190 | } 191 | 192 | export interface BranchQuery extends RefQuery { 193 | readonly remote?: boolean; 194 | } 195 | 196 | export interface Repository { 197 | readonly rootUri: Uri; 198 | readonly inputBox: InputBox; 199 | readonly state: RepositoryState; 200 | readonly ui: RepositoryUIState; 201 | 202 | getConfigs(): Promise<{ key: string; value: string }[]>; 203 | getConfig(key: string): Promise; 204 | setConfig(key: string, value: string): Promise; 205 | getGlobalConfig(key: string): Promise; 206 | 207 | getObjectDetails( 208 | treeish: string, 209 | path: string, 210 | ): Promise<{ mode: string; object: string; size: number }>; 211 | detectObjectType( 212 | object: string, 213 | ): Promise<{ mimetype: string; encoding?: string }>; 214 | buffer(ref: string, path: string): Promise; 215 | show(ref: string, path: string): Promise; 216 | getCommit(ref: string): Promise; 217 | 218 | add(paths: string[]): Promise; 219 | revert(paths: string[]): Promise; 220 | clean(paths: string[]): Promise; 221 | 222 | apply(patch: string, reverse?: boolean): Promise; 223 | diff(cached?: boolean): Promise; 224 | diffWithHEAD(): Promise; 225 | diffWithHEAD(path: string): Promise; 226 | diffWith(ref: string): Promise; 227 | diffWith(ref: string, path: string): Promise; 228 | diffIndexWithHEAD(): Promise; 229 | diffIndexWithHEAD(path: string): Promise; 230 | diffIndexWith(ref: string): Promise; 231 | diffIndexWith(ref: string, path: string): Promise; 232 | diffBlobs(object1: string, object2: string): Promise; 233 | diffBetween(ref1: string, ref2: string): Promise; 234 | diffBetween(ref1: string, ref2: string, path: string): Promise; 235 | 236 | getDiff(): Promise; 237 | 238 | hashObject(data: string): Promise; 239 | 240 | createBranch(name: string, checkout: boolean, ref?: string): Promise; 241 | deleteBranch(name: string, force?: boolean): Promise; 242 | getBranch(name: string): Promise; 243 | getBranches( 244 | query: BranchQuery, 245 | cancellationToken?: CancellationToken, 246 | ): Promise; 247 | getBranchBase(name: string): Promise; 248 | setBranchUpstream(name: string, upstream: string): Promise; 249 | 250 | getRefs( 251 | query: RefQuery, 252 | cancellationToken?: CancellationToken, 253 | ): Promise; 254 | 255 | getMergeBase(ref1: string, ref2: string): Promise; 256 | 257 | tag(name: string, upstream: string): Promise; 258 | deleteTag(name: string): Promise; 259 | 260 | status(): Promise; 261 | checkout(treeish: string): Promise; 262 | 263 | addRemote(name: string, url: string): Promise; 264 | removeRemote(name: string): Promise; 265 | renameRemote(name: string, newName: string): Promise; 266 | 267 | fetch(options?: FetchOptions): Promise; 268 | fetch(remote?: string, ref?: string, depth?: number): Promise; 269 | pull(unshallow?: boolean): Promise; 270 | push( 271 | remoteName?: string, 272 | branchName?: string, 273 | setUpstream?: boolean, 274 | force?: ForcePushMode, 275 | ): Promise; 276 | 277 | blame(path: string): Promise; 278 | log(options?: LogOptions): Promise; 279 | 280 | commit(message: string, opts?: CommitOptions): Promise; 281 | } 282 | 283 | export interface RemoteSource { 284 | readonly name: string; 285 | readonly description?: string; 286 | readonly url: string | string[]; 287 | } 288 | 289 | export interface RemoteSourceProvider { 290 | readonly name: string; 291 | readonly icon?: string; // codicon name 292 | readonly supportsQuery?: boolean; 293 | getRemoteSources(query?: string): ProviderResult; 294 | getBranches?(url: string): ProviderResult; 295 | publishRepository?(repository: Repository): Promise; 296 | } 297 | 298 | export interface RemoteSourcePublisher { 299 | readonly name: string; 300 | readonly icon?: string; // codicon name 301 | publishRepository(repository: Repository): Promise; 302 | } 303 | 304 | export interface Credentials { 305 | readonly username: string; 306 | readonly password: string; 307 | } 308 | 309 | export interface CredentialsProvider { 310 | getCredentials(host: Uri): ProviderResult; 311 | } 312 | 313 | export interface PostCommitCommandsProvider { 314 | getCommands(repository: Repository): Command[]; 315 | } 316 | 317 | export interface PushErrorHandler { 318 | handlePushError( 319 | repository: Repository, 320 | remote: Remote, 321 | refspec: string, 322 | error: Error & { gitErrorCode: GitErrorCodes }, 323 | ): Promise; 324 | } 325 | 326 | export interface BranchProtection { 327 | readonly remote: string; 328 | readonly rules: BranchProtectionRule[]; 329 | } 330 | 331 | export interface BranchProtectionRule { 332 | readonly include?: string[]; 333 | readonly exclude?: string[]; 334 | } 335 | 336 | export interface BranchProtectionProvider { 337 | onDidChangeBranchProtection: Event; 338 | provideBranchProtection(): BranchProtection[]; 339 | } 340 | 341 | export type APIState = 'uninitialized' | 'initialized'; 342 | 343 | export interface PublishEvent { 344 | repository: Repository; 345 | branch?: string; 346 | } 347 | 348 | export interface API { 349 | readonly state: APIState; 350 | readonly onDidChangeState: Event; 351 | readonly onDidPublish: Event; 352 | readonly git: Git; 353 | readonly repositories: Repository[]; 354 | readonly onDidOpenRepository: Event; 355 | readonly onDidCloseRepository: Event; 356 | 357 | toGitUri(uri: Uri, ref: string): Uri; 358 | getRepository(uri: Uri): Repository | null; 359 | init(root: Uri, options?: InitOptions): Promise; 360 | openRepository(root: Uri): Promise; 361 | 362 | registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; 363 | registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; 364 | registerCredentialsProvider(provider: CredentialsProvider): Disposable; 365 | registerPostCommitCommandsProvider( 366 | provider: PostCommitCommandsProvider, 367 | ): Disposable; 368 | registerPushErrorHandler(handler: PushErrorHandler): Disposable; 369 | registerBranchProtectionProvider( 370 | root: Uri, 371 | provider: BranchProtectionProvider, 372 | ): Disposable; 373 | } 374 | 375 | export interface GitExtension { 376 | readonly enabled: boolean; 377 | readonly onDidChangeEnablement: Event; 378 | 379 | /** 380 | * Returns a specific API version. 381 | * 382 | * Throws error if git extension is disabled. You can listen to the 383 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 384 | * to know when the extension becomes enabled/disabled. 385 | * 386 | * @param version Version number. 387 | * @returns API instance 388 | */ 389 | getAPI(version: 1): API; 390 | } 391 | 392 | export const enum GitErrorCodes { 393 | BadConfigFile = 'BadConfigFile', 394 | AuthenticationFailed = 'AuthenticationFailed', 395 | NoUserNameConfigured = 'NoUserNameConfigured', 396 | NoUserEmailConfigured = 'NoUserEmailConfigured', 397 | NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', 398 | NotAGitRepository = 'NotAGitRepository', 399 | NotAtRepositoryRoot = 'NotAtRepositoryRoot', 400 | Conflict = 'Conflict', 401 | StashConflict = 'StashConflict', 402 | UnmergedChanges = 'UnmergedChanges', 403 | PushRejected = 'PushRejected', 404 | ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', 405 | ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', 406 | RemoteConnectionError = 'RemoteConnectionError', 407 | DirtyWorkTree = 'DirtyWorkTree', 408 | CantOpenResource = 'CantOpenResource', 409 | GitNotFound = 'GitNotFound', 410 | CantCreatePipe = 'CantCreatePipe', 411 | PermissionDenied = 'PermissionDenied', 412 | CantAccessRemote = 'CantAccessRemote', 413 | RepositoryNotFound = 'RepositoryNotFound', 414 | RepositoryIsLocked = 'RepositoryIsLocked', 415 | BranchNotFullyMerged = 'BranchNotFullyMerged', 416 | NoRemoteReference = 'NoRemoteReference', 417 | InvalidBranchName = 'InvalidBranchName', 418 | BranchAlreadyExists = 'BranchAlreadyExists', 419 | NoLocalChanges = 'NoLocalChanges', 420 | NoStashFound = 'NoStashFound', 421 | LocalChangesOverwritten = 'LocalChangesOverwritten', 422 | NoUpstreamBranch = 'NoUpstreamBranch', 423 | IsInSubmodule = 'IsInSubmodule', 424 | WrongCase = 'WrongCase', 425 | CantLockRef = 'CantLockRef', 426 | CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', 427 | PatchDoesNotApply = 'PatchDoesNotApply', 428 | NoPathFound = 'NoPathFound', 429 | UnknownPath = 'UnknownPath', 430 | EmptyCommitMessage = 'EmptyCommitMessage', 431 | BranchFastForwardRejected = 'BranchFastForwardRejected', 432 | BranchNotYetBorn = 'BranchNotYetBorn', 433 | TagConflict = 'TagConflict', 434 | } 435 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. --------------------------------------------------------------------------------