├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .prettierrc.json ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── bin └── reset_sandbox.sh ├── docs ├── README.md ├── _media │ ├── sample-build.png │ ├── sample-chore.png │ ├── sample-docs.png │ ├── sample-feat.png │ └── sample-rename.png ├── about.md ├── development │ ├── README.md │ ├── advanced │ │ ├── debugging.md │ │ ├── file-changes.md │ │ ├── maintenance.md │ │ ├── npm-tasks.md │ │ ├── resources.md │ │ ├── sandbox.md │ │ ├── status-vs-diff-index.md │ │ └── structure.md │ ├── build-install.md │ ├── commands.md │ ├── deploy.md │ ├── installation.md │ └── tests.md ├── features.md ├── manual │ ├── README.md │ ├── conventional-commits.md │ ├── installation.md │ ├── uninstall.md │ └── usage.md ├── other │ ├── README.md │ ├── ai-tools.md │ ├── commit-philosophy.md │ ├── credit.md │ ├── functionality.md │ ├── generate-message.md │ ├── plan.md │ ├── purpose-1.md │ ├── purpose-2.md │ ├── reference.md │ ├── terminal-hook.md │ └── unused.md └── quickstart.md ├── hooks └── pre-push ├── images ├── icon.png ├── message-light.svg └── message.svg ├── package-lock.json ├── package.json ├── shell ├── README.md ├── autofill-hook.sh ├── autofill.sh ├── count-files.sh ├── sample.sh └── simple-hook.sh ├── src ├── api │ └── git.d.ts ├── autofill.ts ├── cli.ts ├── extension.ts ├── generate │ ├── action.d.ts │ ├── action.ts │ ├── convCommit.ts │ ├── convCommitConstants.ts │ ├── count.d.ts │ ├── count.ts │ ├── message.ts │ ├── parseExisting.d.ts │ └── parseExisting.ts ├── git │ ├── cli.ts │ ├── parseOutput.d.ts │ └── parseOutput.ts ├── gitExtension.ts ├── lib │ ├── commonPath.ts │ ├── constants.ts │ ├── paths.d.ts │ ├── paths.ts │ └── utils.ts ├── prepareCommitMsg.d.ts ├── prepareCommitMsg.ts ├── test │ ├── generate │ │ ├── action.test.ts │ │ ├── convCommit.test.ts │ │ ├── count.test.ts │ │ ├── message.test.ts │ │ └── parseExisting.test.ts │ ├── git │ │ └── parseOutput.test.ts │ ├── lib │ │ ├── commonPath.test.ts │ │ ├── paths.test.ts │ │ └── utils.test.ts │ └── prepareCommitMsg.test.ts └── workspace.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | 7 | [*.md] 8 | indent_size = 4 9 | 10 | [Makefile] 11 | indent_size = 4 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | parser: "@typescript-eslint/parser", 6 | plugins: ["@typescript-eslint"], 7 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | rules: { 9 | semi: [2, "always"], 10 | "comma-dangle": [2, "always-multiline"], 11 | quotes: [2, "double", { avoidEscape: true }], 12 | "max-len": [2, { code: 100, ignoreUrls: true }], 13 | 14 | "@typescript-eslint/no-unused-vars": 0, 15 | "@typescript-eslint/no-explicit-any": 0, 16 | "@typescript-eslint/explicit-module-boundary-types": 0, 17 | "@typescript-eslint/no-non-null-assertion": 0, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: "26 0 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: ["javascript"] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v1 28 | with: 29 | languages: ${{ matrix.language }} 30 | 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v1 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "docs/**" 7 | - README.md 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 22 21 | 22 | - name: Get cached dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | 30 | - name: Install dependencies 31 | run: npm install 32 | 33 | - name: Lint code 34 | run: npm run lint:check 35 | 36 | - name: Compile 37 | run: npm run compile 38 | 39 | # Use unit rather than `test` command, to skip the pretest step, otherwise 40 | # that would duplicate the steps above. 41 | - name: Unit tests (without coverage report) 42 | run: npm run test:unit 43 | env: 44 | CI: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | node_modules/ 4 | 5 | .nyc_output/ 6 | coverage/ 7 | 8 | .vscode-test/ 9 | out/ 10 | build/*.vsix 11 | 12 | # Temporary git repo in the repo, for testing the extension. 13 | sandbox/ 14 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--disable-extensions", 15 | "--extensionDevelopmentPath=${workspaceFolder}" 16 | ], 17 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 18 | "preLaunchTask": "npm: watch" 19 | }, 20 | { 21 | "name": "Start in Sandbox repo", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "runtimeExecutable": "${execPath}", 25 | "args": [ 26 | "${workspaceFolder}/sandbox", 27 | "--disable-extensions", 28 | "--extensionDevelopmentPath=${workspaceFolder}" 29 | ], 30 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 31 | "preLaunchTask": "npm: watch" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false 4 | }, 5 | "search.exclude": { 6 | "out": true 7 | }, 8 | // Turn off tsc task auto detection since we have the necessary tasks as npm 9 | // scripts. 10 | "typescript.tsc.autoDetect": "off", 11 | 12 | // For Prettier Now extension. I don't like how this has to be set 13 | // vs the the TS JS language features extension. Plus Prettier Now wraps else 14 | // statements weirdly. Oh but Prettier Now removes empty lines for cleanup and 15 | // the other doesn't. So I don't know which to use. Maybe prettier is better 16 | // as it is meant to avoid discussions like this. 17 | "prettier.tabWidth": 2, 18 | 19 | "grammarly.autoActivate": true, 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | }, 19 | { 20 | "type": "npm", 21 | "script": "test", 22 | "group": "test", 23 | "problemMatcher": [] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # Ignoring everything initially keeps this ignore file much simpler. 2 | ** 3 | 4 | # Keep files. 5 | # Note `package.json` and `README.md` are implied. 6 | !LICENSE 7 | 8 | !images/ 9 | 10 | !out/*.js 11 | # Most effective way to ignore `out/test` is to include named directories. 12 | !out/generate/**/*.js 13 | !out/git/**/*.js 14 | !out/lib/**/*.js 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome. 4 | 5 | 6 | ## Q&A 7 | 8 | If you have questions relating to using the installed extension, see the [Q&A][] section on the Marketplace page. 9 | 10 | 11 | ## Issues 12 | 13 | Create an issue under [Issues][] to request a feature, bug fix, or doc change. 14 | 15 | 16 | ## Pull Requests 17 | 18 | Create a Pull Request under [Pull Requests][]. If you have questions or the change is complex, preferably make an issue first and discuss in then, then follow with a PR later. 19 | 20 | 21 | ## Discussions 22 | 23 | Use the [Discussions][] section on GitHub too if you need help working with the code. 24 | 25 | 26 | 27 | [Issues]: https://github.com/MichaelCurrin/auto-commit-msg/issues 28 | [Pull Requests]: https://github.com/MichaelCurrin/auto-commit-msg/pulls 29 | [Discussions]: https://github.com/MichaelCurrin/auto-commit-msg/discussions 30 | [Q&A]: https://marketplace.visualstudio.com/items?itemName=MichaelCurrin.auto-commit-msg&ssr=false#qna 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 - 2025 MichaelCurrin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PUBLISHER_NAME = MichaelCurrin 2 | 3 | 4 | default: install 5 | 6 | all: hooks install test build 7 | 8 | h help: 9 | @grep '^[a-z#]' Makefile 10 | 11 | 12 | .PHONY: hooks 13 | hooks: 14 | cd .git/hooks && ln -s -f ../../hooks/pre-push pre-push 15 | 16 | install: 17 | npm install 18 | 19 | outdated: 20 | npm outdated 21 | 22 | upgrade: 23 | npm upgrade 24 | 25 | # Upgrade vscode types package and use it to set the engine version. 26 | upgrade-engine: 27 | npm install @types/vscode@latest 28 | VS_CODE_VERSION=$$(npm view @types/vscode version) && \ 29 | sed -i "s/\"vscode\": \"\^.*\"/\"vscode\": \"^$$VS_CODE_VERSION\"/" \ 30 | package.json package-lock.json 31 | 32 | fmt: 33 | npm run fmt:fix 34 | 35 | l lint: 36 | npm run lint:fix 37 | 38 | fix: fmt lint 39 | 40 | t test: fix 41 | npm run cover 42 | npm run cover:report 43 | npm run cover:check 44 | 45 | q test-quick: 46 | npx tsc -p . 47 | npm run test:unit 48 | 49 | ### Build 50 | 51 | .PHONY: build 52 | build: 53 | npm run build 54 | 55 | # Build then install in VS Code. 56 | e ext: 57 | npm run checks 58 | npm run ext 59 | 60 | ### Deploy 61 | 62 | login: 63 | npx vsce login $(PUBLISHER_NAME) 64 | 65 | # Increment tag, publish to Marketplace, then install globally. 66 | publish-M: 67 | npx vsce publish major 68 | npm run ext 69 | 70 | publish-m: 71 | npx vsce publish minor 72 | npm run ext 73 | 74 | publish-b: 75 | npx vsce publish patch 76 | npm run ext 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Commit Message ⚙️ 🧙‍♂️ ✉️ 2 | > A VS Code extension to generate a smart commit message based on file changes 3 | 4 | 5 | 6 | [![Node CI](https://github.com/MichaelCurrin/auto-commit-msg/workflows/Node%20CI/badge.svg)](https://github.com/MichaelCurrin/auto-commit-msg/actions?query=workflow:"Node+CI") 7 | [![CodeQL](https://github.com/MichaelCurrin/auto-commit-msg/workflows/CodeQL/badge.svg)](https://github.com/MichaelCurrin/auto-commit-msg/actions?query=workflow%3ACodeQL) 8 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license "Go to License section") 9 | [![Contributions - welcome](https://img.shields.io/badge/Contributions-welcome-blue)](/CONTRIBUTING.md "View contributing doc") 10 | 11 | 12 |
13 | 14 | 15 | 17 | 18 | Logo 21 | 22 | ![VS Code extension marketplace version](https://img.shields.io/visual-studio-marketplace/v/MichaelCurrin.auto-commit-msg) 23 | ![VS Code extension installs](https://img.shields.io/visual-studio-marketplace/i/MichaelCurrin.auto-commit-msg) 24 | ![VS Code extension rating](https://img.shields.io/visual-studio-marketplace/r/MichaelCurrin.auto-commit-msg) 25 | ![maintained - yes](https://img.shields.io/badge/maintained-yes-blue) 26 | 27 | 28 | 29 | 34 | 35 |
36 | 37 | 38 | ## Preview 39 | 40 | Starting from an empty commit message, the extension created a recommended message and populated it inside the Git pane of VS Code: 41 | 42 |
43 | sample screenshot of chore 46 |
47 | 48 | 49 | 50 | 51 | ## Getting started 52 | 53 | How to install and run the extension in VS Code. 54 | 55 |
56 | 57 | [![docs - Getting started](https://img.shields.io/badge/docs-getting_started-2ea44f?style=for-the-badge)](/docs/quickstart.md) 58 | 59 |
60 | 61 | 62 | ## Features 63 | 64 | Just click the extension's one **button** in the Git pane. 65 | 66 | This is what the extension can do: 67 | 68 | - Look at any **staged** changes files, otherwise falls back to all unstaged changes. 69 | - Generate a commit message, which you can use or edit. 70 | - It can describe a variety of changes - when a file is added, removed, moved, renamed, etc. 71 | - Can handle multiple files at once. 72 | - Based on paths and extensions, infers a **Conventional Commit** prefix type e.g. `feat`, `chore`, `ci`, `build`, `build(deps)`, `docs`. 73 | 74 | See more info on the [Features](/docs/features.md) page in the docs. 75 | 76 | 77 | ## Comparison with other extensions 78 | 79 | Other extensions usually require some manual input, such as selecting prefix type from a droplist or writing a commit message by hand along with other form parameters. 80 | 81 | This extension takes _zero_ parameters. Just click a button. 82 | 83 | ## Why not generate a commit message with AI? 84 | 85 | This extension does not use AI. With the explosion of AI tools, you can find alternatives to this extension which use do AI - see notes here under [AI tools](/docs/other/ai-tools.md). 86 | 87 | 88 | ## Sample usage 89 | 90 | Here are some screenshots of what messages the extension generates based on changed files. 91 | 92 | If you created a new file and staged it: 93 | 94 |
95 | feat 96 |
97 | 98 | If you updated a build-related file: 99 | 100 |
101 | build 102 |
103 | 104 | If updated a file in `docs/` or a `README.md` anywhere: 105 | 106 |
107 | docs 108 |
109 | 110 | If you renamed a file: 111 | 112 |
113 | rename 114 |
115 | 116 | 117 | ## Documentation 118 | 119 | Guides for installing and using the pre-built extension and for developers to build from source code. 120 | 121 |
122 | 123 | [![view - Documentation](https://img.shields.io/badge/view-Documenation-blue?style=for-the-badge)](/docs/) 124 | 125 |
126 | 127 | 128 | ## Contributing 129 | 130 | See the [Contributing](/CONTRIBUTING.md) guide. 131 | 132 | 133 | ## License 134 | 135 | Released under [MIT](/LICENSE) by [@MichaelCurrin](https://github.com/MichaelCurrin). 136 | 137 | See the [Credit](/docs/other/credit.md) doc for more info. 138 | -------------------------------------------------------------------------------- /bin/reset_sandbox.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Set up the sandbox directory as a repo with some sample activity. 3 | set -e 4 | 5 | DIR='sandbox' 6 | 7 | if [[ -d "$DIR" ]]; then 8 | rm -rf "$DIR" 9 | fi 10 | 11 | git init "$DIR" --quiet 12 | cd "$DIR" 13 | 14 | # Create a commit, so that the extension can run diff-index, which requires an 15 | # initial commit. 16 | echo '# Sandbox' >'README.md' 17 | echo 'console.log("Hello, Foo!");' >foo.js 18 | echo 'console.log("Hello, Fizz!");' >fizz.js 19 | echo 'console.log("Hello, Buzz!");' >buzz.js 20 | echo 'console.log("Hello, Bazz!");' >bazz.js 21 | git add -A 22 | git commit -m "Initial commit in $DIR" 23 | 24 | # Modify. 25 | echo 'console.log("Hello, Foobar!");' >>foo.js 26 | # Add. 27 | echo 'console.log("Hello, Bar!");' >bar.js 28 | # Rename 29 | mv fizz.js fizzy.js 30 | # Move 31 | mkdir -p my_subdir 32 | mv bazz.js my_subdir 33 | # Delete. 34 | rm buzz.js 35 | 36 | # Special characters. 37 | echo '# Special characters' >'spëcial châracters.md' 38 | 39 | echo '---' 40 | git -c 'core.quotePath=false' status --short 41 | echo '---' 42 | git -c 'core.quotePath=false' diff-index --name-status --find-renames --find-copies --no-color HEAD 43 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Auto Commit Message documentation ⚙️ 🧙‍♂️ ✉️ 2 | 3 | This project serves to prepare a smart commit message for you, to make your development flow smoother. 4 | 5 | 6 | ## Overview 7 | 8 | - [Quickstart](quickstart.md) 9 | - [About](about.md) 10 | - [Features](features.md) 11 | - What it can do, upcoming features, and how it works 12 | - [User manual](manual/) 13 | - How to install and use the installed extension. 14 | - [Development](development/) 15 | - Guide for developers to set up and run locally. 16 | - Useful for testing your changes before contributing a PR. 17 | 18 | 31 | -------------------------------------------------------------------------------- /docs/_media/sample-build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/docs/_media/sample-build.png -------------------------------------------------------------------------------- /docs/_media/sample-chore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/docs/_media/sample-chore.png -------------------------------------------------------------------------------- /docs/_media/sample-docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/docs/_media/sample-docs.png -------------------------------------------------------------------------------- /docs/_media/sample-feat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/docs/_media/sample-feat.png -------------------------------------------------------------------------------- /docs/_media/sample-rename.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/docs/_media/sample-rename.png -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | A VS Code extension that gives you smart commit message suggestions. For the times where all your need is a simple message. 4 | 5 | It looks at the path of a file that changed and how it changed, then pushes the commit message to the Git pane in VS Code. You can edit or erase the message if you don't like it. 6 | 7 | It can make a message to **describe a change** for a single file to commit. Including "create", "update", "remove", "rename" and "move" - along with the filename. Or the path, like for a move. See the [message.test.ts](/src/test/generate/message.test.ts) test spec. 8 | 9 | In many cases, it can also provide an appropriate **Conventional Commit** type for you, as a commit message prefix. 10 | 11 | 12 | ## When auto-generated messages are good 13 | 14 | This is a time-saving tool. You get to spend more time writing code and solving problems. And less time on figuring out what to write for a commit message or typing the message. 15 | 16 | You can probably use this tool to generate messages for **80%** of your commits. Where the changes are rather mundane. And where the effort and time to write out a commit 17 | 18 | But remember to **manually** write explanatory messages for the other 20% of the time, where a commit message composed by a human is valuable. Or take the generated message and tweak it with more detail. 19 | -------------------------------------------------------------------------------- /docs/development/README.md: -------------------------------------------------------------------------------- 1 | # Development 2 | > How to setup, run, and improve this extension in a local dev environment 3 | 4 | [![Known Vulnerabilities](https://snyk.io/test/github/MichaelCurrin/auto-commit-msg/badge.svg?targetFile=package.json)](https://snyk.io/test/github/MichaelCurrin/auto-commit-msg?targetFile=package.json) 5 | [![Made with Node.js](https://img.shields.io/badge/dynamic/json?label=node&query=%24.engines%5B%22node%22%5D&url=https%3A%2F%2Fraw.githubusercontent.com%2FMichaelCurrin%2Fauto-commit-msg%2Fmaster%2Fpackage.json&logo=node.js&logoColor=white)](https://nodejs.org "Go to Node.js homepage") 6 | [![Package - Typescript](https://img.shields.io/github/package-json/dependency-version/MichaelCurrin/auto-commit-msg/dev/typescript?logo=typescript&logoColor=white)](https://www.npmjs.com/package/typescript "Go to TypeScript homepage") 7 | 8 | Requires VS Code version: [![Made for VS Code](https://img.shields.io/badge/dynamic/json?label=vscode&query=%24.engines.vscode&url=https%3A%2F%2Fraw.githubusercontent.com%2FMichaelCurrin%2Fauto-commit-msg%2Fmaster%2Fpackage.json&logo=visualstudiocode&logoColor=white)](https://code.visualstudio.com/ "Go to VS Code homepage") 9 | 10 | This guide is for anyone who wants to contribute to this repo or who just wants to explore this extension's code and how it works in VS Code. 11 | 12 | 13 | ## Overview 14 | 15 | - [Installation](installation.md) - Start with this doc to install dependencies. 16 | - [Export](export.md) - how to build the extension locally and install it in VS Code. Just a single command is needed. 17 | - [Deploy](deploy.md) - this doc covers the CI/CD pipeline and publishing releases. 18 | 19 | 20 | ## Install extension globally 21 | 22 | ### Roll your own 23 | 24 | See the [Export](export.md) doc to install a dev version of the package. 25 | 26 | ### Install pre-built extension 27 | 28 | See [Quickstart](/docs/quickstart.md) doc to download and install a _pre-built_ version of the extension from the Marketplace. 29 | -------------------------------------------------------------------------------- /docs/development/advanced/debugging.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | > How to deal with errors in the extension 3 | 4 | ## Test shell commands 5 | 6 | This snippet of code can be used to for testing that a basic command works without error and output can be shown. 7 | 8 | ```typescript 9 | /** 10 | * Debug tool for checking that a basic command works. 11 | * Replace the `makeAndFillCommitMsg` call with `test` to use this. 12 | */ 13 | async function _test(repository: Repository) { 14 | vscode.window.showInformationMessage("Testing") 15 | 16 | let cwd = repository.rootUri.fsPath; 17 | 18 | vscode.window.showInformationMessage(cwd) 19 | 20 | const resp = await _execute(cwd, 'git --version') 21 | vscode.window.showInformationMessage(`${resp.stdout} -- ${resp.stderr}`) 22 | } 23 | ``` 24 | 25 | ### Background 26 | 27 | This was prompted by issue [#93](https://github.com/MichaelCurrin/auto-commit-msg/issues/93) on Windows. 28 | 29 | The output in the debug mode did not work because the shell command failed to spawn at all using `exec()` in Node. 30 | 31 | The `cwd` value was incorrect from the existing logs which I didn't notice initially but using the test above made it clear. -------------------------------------------------------------------------------- /docs/development/advanced/file-changes.md: -------------------------------------------------------------------------------- 1 | # File changes 2 | 3 | Describe how files changed. 4 | 5 | 6 | ## Git CLI 7 | 8 | See `DESCRIPTION` enum in the [constants.ts][] module. This is a description of _how_ a file changed - it will sometimes be used in a generated description as the [Conventional Commits][] doc. 9 | 10 | This enum is a mapping of single-character keys and the verb, such as `M` for `modified`. 11 | 12 | When running either subcommand, you might get the short or long form of a change. 13 | 14 | The enum's _keys_ are from status when using the status `--short` output or standard `diff-index` output. 15 | 16 | e.g. 17 | 18 | ```console 19 | $ git status --short 20 | M abc.txt 21 | A def.txt 22 | $ g diff-index --name-status HEAD 23 | M abc.txt 24 | A def.txt 25 | ``` 26 | 27 | The enum's _values_ are the human-readable values from the status long output (standard with no flag). The `diff-index` command has no long output for these verbs, as far as I can tell. 28 | 29 | e.g. 30 | 31 | ```console 32 | $ git status 33 | ... 34 | modified: abc.txt 35 | new file: def.txt 36 | ``` 37 | 38 | ### Origin 39 | 40 | For more info, see the Git docs for either `git status` or `git diff-index`. 41 | 42 | From [git-diff-index][]: 43 | 44 | > Possible status letters are: 45 | > 46 | > - A: addition of a file 47 | > - C: copy of a file into a new one 48 | > - D: deletion of a file 49 | > - M: modification of the contents or mode of a file 50 | > - R: renaming of a file 51 | > - T: change in the type of the file 52 | > - U: file is unmerged (you must complete the merge before it can be committed) 53 | > - X: "unknown" change type (most probably a bug, please report it) 54 | 55 | Note this extension does not care about the last 3 kinds. 56 | 57 | ### Create note 58 | 59 | For the `A` key of the enum, `create` was used as a more natural form than `add` or `addition`. 60 | 61 | ### Copy note 62 | 63 | The 'copied' case is very _rare_. I've noted here how it works. 64 | 65 | I've only come across it once using this extension and it was like this: 66 | 67 | - One file `abc.txt` was updated (empty content replaced with text). 68 | - Another file `def.txt` was created (empty content). 69 | 70 | Both were staged and a message was generated. And then `diff-index` appears like to see `def.txt` as a copy of what `abc.txt` _was_ before it was modified. 71 | 72 | Result: 73 | 74 | ```json 75 | ["M abc.txt", "C100 abc.txt def.txt"] 76 | ``` 77 | 78 | 79 | ## Actions list 80 | 81 | See `ACTION` in the [constants.ts][] module. 82 | 83 | These are based on Git syntax as in the `DESCRIPTION`. Except that values are in the _active_ 84 | voice rather than the past tense, in order fit the Conventional Commit style. Plus, 'update' is 85 | used as a more natural word than 'modify'. 86 | 87 | Note that 'move' will be included in the 'rename' case and this project detects move versus rename with other longer. 88 | 89 | 90 | [constants.ts]: /src/lib/constants.ts 91 | [Conventional Commits]: /docs/manual/conventional-commits.md 92 | [git-diff-index]: https://git-scm.com/docs/git-diff-index 93 | -------------------------------------------------------------------------------- /docs/development/advanced/maintenance.md: -------------------------------------------------------------------------------- 1 | # Maintenance 2 | 3 | ## Dependencies 4 | 5 | When upgrading `@types/vscode` in [package.json](/package.json), you must also upgrade the `engines.vscode` value manually. 6 | 7 | Or you'll get an error when packaging the extension, like: 8 | 9 | ``` 10 | ERROR @types/vscode ^1.53.0 greater than engines.vscode ^1.52.0. Consider upgrade engines.vscode or use an older @types/vscode version 11 | ``` 12 | 13 | Running the `test` command will not tell you about the error. 14 | 15 | 16 | ## Images 17 | 18 | The extension icon listed in `package.json` must not be an SVG because of security limitations by VS Code. A PNG works fine. 19 | 20 | SVGs are used for "commands" though, for the extension's button. 21 | 22 | See [images](/images/). 23 | -------------------------------------------------------------------------------- /docs/development/advanced/npm-tasks.md: -------------------------------------------------------------------------------- 1 | # NPM tasks 2 | 3 | 4 | ## Clean 5 | 6 | This will clear the unversioned `out` directory - useful to get rid of files after renaming or deleting TS files. This will keep any hidden directories like `.vscode-test` which has a large binary for integration tests. 7 | 8 | ```sh 9 | $ npm run clean 10 | ``` 11 | 12 | A few problems have been resolved by running the clean command, so this is now part of the `build` step so it runs every time when doing tests or running the extension. Note that the build/compile step happens as part of `watch` too, so it is covered there. 13 | -------------------------------------------------------------------------------- /docs/development/advanced/resources.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | 4 | See also the [License](/README.md#license) section for links to repos. 5 | 6 | NPM packages that parse git status output: 7 | 8 | 9 | ## git-status 10 | 11 | [NPM - git-status](https://www.npmjs.com/package/git-status) 12 | 13 | Published in 2020. This _has_ to use the git status command so is limiting. It wraps `parse-git-status` - see [index.js](https://github.com/IonicaBizau/git-status/blob/master/lib/index.js). 14 | 15 | 16 | ## parse-git-status 17 | 18 | [NPM - parse-git-status](https://www.npmjs.com/package/parse-git-status) 19 | 20 | Published in 2016 and no activity since. 21 | 22 | See [index.js](https://github.com/jamestalmage/parse-git-status/blob/master/index.js) - that is the core logic and a `DESCRIPTIONS` mapping. This package can be used against string output so does not require actually running git status. It doesn't have Types though and also from the tests it doesn't support renaming properly. 23 | 24 | The output looks like this: 25 | 26 | ```javascript 27 | parseGitStatus(output) 28 | { 29 | x: 'X DESCRIPTION', 30 | y: 'Y DESCRIPTION', 31 | to: 'TO PATH', 32 | from: 'FROM PATH OR NULL' 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/development/advanced/sandbox.md: -------------------------------------------------------------------------------- 1 | # Run extension in sandbox mode 2 | 3 | Start the extension for local development. 4 | 5 | This allows manual integration tests. Here we set up a separate sandboxed VS Code instance in an new window. That window has all extensions **disable** except the extension we are working on. And your original VS Code instance will not be affected. 6 | 7 | If you prefer not use this approach, you can just compile and install the extension globally. It will override the existing extension though. 8 | 9 | 10 | ## Start sandbox 11 | 12 | These are configured in the `.vscode/launch.json` config. 13 | 14 | Follow these steps: 15 | 16 | 1. Open VS Code at this repo if you haven't already. 17 | 1. Go to the _Debug_ tab. 18 | 1. Select one of two tasks: 19 | - _Run Extension_ task. 20 | - This will start in a default directory - such as your user's home directory. 21 | - You might want to use _File_ / _Open_ to change the sandbox window to a repo what has more content to play with. This will be _remembered_ on later runs. Unfortunately if you changed your VS Code settings to open in a new window on Open, then the extension setup will be undone. 22 | - _Start in Sandbox repo_ task. 23 | - For more reliable and consistent behavior. 24 | - This will run against the `sandbox` directory in the project, which is a separate Git repo where you can make files and commits as you like. 25 | - NB. You must run `npm run sb` command **first** to ensure this directory exists, then run the debug action. You can also this NPM command again whenever you want to clear the space and start over. 26 | 1. Click the run button. 27 | 28 | That will start a new sandboxed VS Code session which has the extension built using the local code and _enabled_, and all other extensions _disabled_. At a lower level, it runs `npm compile` and `npm watch`. If you want to keep extensions enabled, remove the `--disable-extensions` flag in the launch config. 29 | 30 | What is especially useful about this is that whenever an extension action is performed in the sandboxed VS Code window, if there are any logs for that then those will appear in the Debug Console of the _original_ VS Code window. 31 | 32 | The code for the extension is in [src](/src/). 33 | 34 | 35 | ## Reload 36 | 37 | The `watch` task is supposed to be running in the background but I haven't actually seen it actually pick up changes and affect what I see in the debugging window. 38 | 39 | So if you make a change to your source code, in the original repo you must use the green _Restart_ circle in the debugger to reload the extension in the sandbox window. 40 | 41 | If you don't see code changes appearing, you may need to stop and start the debugger afresh. 42 | -------------------------------------------------------------------------------- /docs/development/advanced/status-vs-diff-index.md: -------------------------------------------------------------------------------- 1 | # status vs diff-index 2 | 3 | Git subcommands used by this extension to check which filepaths changed and how they changed. 4 | 5 | See [parseOutput.ts](/src/git/parseOutput.ts) module that handles output of two similar Git subcommands discussed below. 6 | 7 | 8 | ## History and motivation 9 | 10 | This project was initially built around [status](#status), as that was the subcommand use by another extension that this extension was based on. But, now the [diff-index](#diff-index) approach is used instead. 11 | 12 | Using `status` is friendly for everyday use as developer. The `diff-index` subcommand is not for everyday use - you can to add a path like `HEAD` and you need to add flags to get sensible output. 13 | 14 | Both `status` and `diff-index` can be used to see a list of paths and how they changed, using `from` and `to` as a pair of paths when moving or renaming a file. 15 | 16 | Why use `diff-index` and not `status`? Using the former makes things more **predictable** when parsing output. Since the `from` file is always first, from left to right. While `status` has it on the right, which is hard because it is not always there. There might be other reasons I can't remember, maybe because there is a percentage similarity that shows up in `diff-index` for renames/moves. 17 | 18 | 19 | ## status 20 | 21 | The well-known `git status` subcommand. 22 | 23 | ```sh 24 | $ git status [FLAGS] --short 25 | ``` 26 | 27 | > git-status - Show the working tree status 28 | 29 | e.g. 30 | 31 | ```console 32 | $ git status 33 | modified: abc.txt 34 | $ git status --short 35 | M abc.txt 36 | ``` 37 | 38 | Sample output of multiple lines. Note use of spaces not tabs. 39 | 40 | ```console 41 | $ git status --short 42 | R LICENSE -> LIC 43 | M docs/development/advanced/status-vs-diff-index.md 44 | M src/test/git/parseOutput.test.ts 45 | ``` 46 | 47 | 48 | ## diff-index 49 | 50 | The lesser-known `git diff-index` subcommand. This is not so usable for day to to day use in the CLI but is great for scripts, or a project such as this one. 51 | 52 | ```sh 53 | $ git diff-index [FLAGS] PATH 54 | ``` 55 | 56 | > Compare a tree to the working tree or index 57 | 58 | Notes: 59 | 60 | - Output has tab separator between columns. 61 | - Use `--cached` for _only_ staged changes. Note that the only way to pick up a new file or detect a move/rename pair properly with `diff-index` is to stage changes first and then use this flag. 62 | - Use `--name-status` to show names and status of changed files. 63 | - Use `-M` to detect renames (i.e. move or rename a file, stage both paths, then run the command with this flag to see it appear as `R100` or similar). 64 | - The path is required - `HEAD` works fine. 65 | 66 | e.g. 67 | 68 | ```console 69 | $ git diff-index HEAD 70 | :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M abc.txt 71 | ``` 72 | 73 | ```console 74 | $ git diff-index --name-status HEAD 75 | M abc.txt 76 | ``` 77 | 78 | Two files changed, with long paths: 79 | 80 | ```console 81 | $ git diff-index --name-status HEAD 82 | M docs/development/advanced/status-vs-diff-index.md 83 | M src/test/git/parseOutput.test.ts 84 | ``` 85 | 86 | For a move or rename: 87 | 88 | ```console 89 | $ mv LICENSE LIC 90 | $ git add . 91 | $ git diff-index --name-status -M HEAD 92 | R100 LICENSE LIC 93 | ``` 94 | 95 | Even though you might be able to select the output in the console as spaces, it is actually tabs. Check with a tool that can show hidden characters. 96 | 97 | ```console 98 | $ git diff-index --name-status HEAD -M | bat -A 99 | ───────┬───────────────────────────────────────────────────────── 100 | │ STDIN 101 | ───────┼───────────────────────────────────────────────────────── 102 | 1 │ R100├──┤LICENSE├──┤LIC␊ 103 | 2 │ M├──┤docs/development/advanced/status-vs-diff-index.md␊ 104 | 3 │ M├──┤src/test/git/parseOutput.test.ts␊ 105 | ``` 106 | 107 | ### Limitation of diff-index 108 | 109 | #### Summary 110 | 111 | New files and moved/renamed files _always_ need to be staged for `git diff-index` to see them. Just remember to do this yourself, because the extension won't see the `U` untracked files otherwise. 112 | 113 | I consider this is an acceptable limitation of functionality to keep the extension code simple. 114 | 115 | #### Details 116 | 117 | The `diff-index` subcommand **cannot** see new or moved/renamed files unless you **stage** them. This is okay. Because you just need to stage a file and then the extension can see it. 118 | 119 | And in the case of renaming/moving files, there's a limitation of git that can't be overcome - you need to **stage** the old and new paths anyway for `git` to see them as **one file**, regardless of using `status` or `diff-index`. 120 | 121 | The `git status` subcommand _can_ handle new _untracked_ files. But the effort to rewrite a chunk of the extension to use a different Git subcommand is not worth it, and won't solve the rename/move case anyway. 122 | 123 | So we just keep things simple to avoid bloating the codebase (adding the ability to use two similar subcommands and handle them both well is not sensible when one subcommand works great for most things). 124 | 125 | You can still do what you need to - just remember to stage files if you need the extension to recognize them. 126 | 127 | 128 | ## Find renames 129 | 130 | If you move/rename a file and stage that and you also change the contents, you can get `git diff-index` to recognise that as a rename with modification. 131 | 132 | This is possible using the `-M` or `--find-renames` flag, which uses the default 50% similarity. 133 | 134 | ```console 135 | $ git diff-index HEAD --name-status -M 136 | R099 package.json shell/package.json 137 | ``` 138 | 139 | This flag also works for `git status`. 140 | -------------------------------------------------------------------------------- /docs/development/advanced/structure.md: -------------------------------------------------------------------------------- 1 | # Structure 2 | 3 | The code from the Git Prefix was used to set up [src](/src) directory for a couple of TS files and the tests. 4 | 5 | The [generate](/src/generate/) module followed later as logic that will work standalone in the CLI for a hook. 6 | -------------------------------------------------------------------------------- /docs/development/build-install.md: -------------------------------------------------------------------------------- 1 | # Build and install 2 | 3 | Build the extension using Node and then install it in your IDE, using the latest code in the repo. This is ideal for testing out local changes you've made in the extension, without creating a tag yet. 4 | 5 | Navigate to the repo root and then run this: 6 | 7 | ```sh 8 | $ make ext 9 | ``` 10 | 11 | That will do the following: 12 | 13 | 1. Run **lint** checks. 14 | 1. **Build** the extension using the current codebase and output as an `.vsix` archive file to the `build` directory. Performs all necessary code quality checks. 15 | 1. **Install** the extension globally in VS Code. 16 | 17 | You should then **restart** VS Code. 18 | 19 | You can find the extension in the _Extension_ tab. 20 | 21 | ## Issues installing using WSL 22 | 23 | If using WSL, then after the build is complete you might get an error: 24 | 25 | ``` 26 | ERROR: UtilConnectToInteropServer 27 | ``` 28 | 29 | In that case, you'll need to install from the `.vsix` file yourself: 30 | 31 | 1. Open the VS Code _Command Palette_ under _View_. 32 | 1. Select the option _Extension: Install from VSIX..._. 33 | 1. Enter the path to the file e.g. `/home/my-user/repos/auto-commit-msg/build/auto-commit-msg-0.25.1.vsix`. 34 | 35 | ## Notes 36 | 37 | About the `ext` command in [package.json](/package.json): 38 | 39 | - We use the `--force` flag to allow **downgrade** to an older version, according to the CLI output help. 40 | - The command sorts by _time_ to find the latest version. Since sorting by name is _not_ reliable, such as when the version is `0.9.0` and `0.10.0` and the latter is meant to be higher but appears like a lower version) 41 | -------------------------------------------------------------------------------- /docs/development/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | > Guide for running tasks in this project 3 | 4 | 5 | ## Overview 6 | 7 | There are just a _few_ important or frequent `npm` commands to know for this project. To make those easier to access and remember, some shortcut commands have been set up. 8 | 9 | This has been done with `make` as a task runner that wraps `npm run`. These tasks are covered in the [Makefile](/Makefile). They can be run with `make TARGET` for convenience. This is standard on macOS and Linux but you will need to install `make` on Windows. 10 | 11 | See [package.json](/package.json) for all the underlying NPM commands. Note that `.` is better than `src`, as then the configs like `tsconfig.json` can be found - otherwise you'll get an error. 12 | 13 | 14 | See also the [NPM tasks](advanced/npm-tasks.md) doc. 15 | 16 | 17 | ## Run tasks 18 | 19 | Run major tasks to check the project for issues. 20 | 21 | ```sh 22 | $ make all 23 | ``` 24 | 25 | This will update hooks, install packages, run all checks, and attempt to build the app. 26 | 27 | This is useful for bootstrapping the project on a fresh clone, or before pushing commits. This task also runs as part of the `pre-push` hook after that has been set up. 28 | 29 | 30 | ## Run checks 31 | 32 | Note these lint and test steps happen in the CI/CD flow - see [Deploy](deploy.md). 33 | 34 | ### Format 35 | 36 | Apply Prettier formatting to scripts. 37 | 38 | ```sh 39 | $ make fmt 40 | ``` 41 | 42 | ### Lint 43 | 44 | Run ESLint against TS files for a report and fixing problems where possible. 45 | 46 | ```sh 47 | $ make lint 48 | ``` 49 | 50 | Note that linting will not pick up on TypeScript compilation errors, but that can be done using the `npm run compile` step. This runs as part of [Run tests](#run-tests) section. 51 | 52 | ### Run tests 53 | 54 | See the [Tests](tests.md) doc for more. 55 | 56 | Skip clean and style steps, for even faster results such and when editing test spec files. At the risk of inconsistencies sometimes, if a file is renamed or moved. 57 | 58 | ```sh 59 | $ make test-quick 60 | ``` 61 | 62 | ### Run all checks 63 | 64 | Clean output, format code, and then run unit tests including code coverage. 65 | 66 | ```sh 67 | $ make test 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/development/deploy.md: -------------------------------------------------------------------------------- 1 | # Deploy 2 | > Build and publish a release 3 | 4 | 5 | ## Checks 6 | 7 | A CI/CD flow is set up on GitHub Actions to compile the app and run checks against it, but not publish it. This is run on every push. 8 | 9 | See the [main.yml](/.github/workflows/main.yml) config file. 10 | 11 | 12 | ## Commands 13 | 14 | ### List 15 | 16 | Preview what will be included in the `.vsix` file. 17 | 18 | ```sh 19 | $ make ls 20 | ``` 21 | 22 | ### Publish 23 | 24 | _This section is only relevant for the **maintainer** of this repo, as access to publishing to VS Code Marketplace requires authorization._ 25 | 26 | See [Publish recipe][] for more detailed steps. 27 | 28 | Store a token for Azure DevOps - this only needs to be done once and grants access to publishing to VS Code Marketplace. 29 | 30 | ```sh 31 | $ make login 32 | ``` 33 | 34 | Tag and push to GitHub and VS Code Marketplace. For major, minor, or bug/patch levels respectively. 35 | 36 | ```sh 37 | $ make publish-M 38 | $ make publish-m 39 | $ make publish-b 40 | ``` 41 | 42 | [Publish recipe]: https://michaelcurrin.github.io/code-cookbook/recipes/other/vs-code-extensions/publish.html 43 | -------------------------------------------------------------------------------- /docs/development/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | > Install dependencies 3 | 4 | 5 | ## Requirements 6 | 7 | - [Node](nodejs.org/) 8 | - [VS Code](https://code.visualstudio.com/) 9 | 10 | 11 | ## Install system dependencies 12 | 13 | Install Node.js - follow [instructions](https://gist.github.com/MichaelCurrin/aa1fc56419a355972b96bce23f3bccba) in gist. 14 | 15 | 16 | ## Clone 17 | 18 | ```sh 19 | $ git clone git@github.com:MichaelCurrin/auto-commit-msg.git 20 | $ cd auto-commit-msg 21 | ``` 22 | 23 | 24 | ## Install project dependencies 25 | 26 | ```sh 27 | $ make install 28 | ``` 29 | 30 | ## Upgrade project dependencies 31 | 32 | For project maintainers. 33 | 34 | ```sh 35 | $ make outdated 36 | ``` 37 | 38 | ```sh 39 | $ make upgrade 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/development/tests.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | 4 | ## Unit tests 5 | 6 | This will clean the output directory, compile files and then run unit tests. No formatting or linting. 7 | 8 | ```sh 9 | $ npm test 10 | ``` 11 | 12 | ## Integration tests 13 | 14 | There are no integration tests in this project. 15 | 16 | For the `git-prefix` project this project was partly based on, unfortunately the tests were poor there, so I didn't copy over the extension tests, but I could bring back some from tag `v0.6.0` so there are integration tests if I think I need them. 17 | 18 | Most of the logic is what happens internally so it is easy to test with unit tests. There is one frontend button and it just pushes a message, so there is not that much that can go wrong in the UI. 19 | 20 | What would be more useful is testing the integration with `git` - namely using actual output from `git` commands. For now, the unit tests are created using output copied from git command output, so that is covered. It is possible to add integration tests for this area, but it would end up duplicating the unit tests - unless the integration is just focus on git commands to expected strings which is testing git itself and not the extension. 21 | 22 | Notes: 23 | 24 | - This extension was built around Git Prefix, but unfortunately the integration tests of Git Prefix extension there are not useful, so I left out integration tests out of my project. I also noticed that downloading of VS Code as an NPM script is different but clear compared with _Git Semantic Commit_ approach. I don't know which is best practice - need to look at some more VS Code samples. 25 | - _Git Semantic Commit_ extension does in-depth integration tests and even creates a separate new repo in a subfolder to run activity in, so I could look at bringing this in to mine. 26 | 27 | 28 | ## Coverage 29 | 30 | Test with code coverage, as a text report. 31 | 32 | ```sh 33 | $ npm run cover 34 | ``` 35 | 36 | Generate a visual multi-page HTML report. 37 | 38 | ```sh 39 | $ npm run cover:report 40 | ``` 41 | 42 | See the main page generated as `coverage/lcov-report/index.html`. 43 | 44 | This can be viewed using a static site server. 45 | 46 | ```sh 47 | $ cd coverage/lcov-report 48 | $ python3 -m http.server 49 | ``` 50 | 51 | Then open as: 52 | 53 | - http://localhost:8000 54 | 55 | Or set up VS Code's _Live Server_ to start on that `index.html` page. That will hot-reload the browser tab when the HTML files change - this is useful if you are updating your tests and want to see the browser reflect changes to coverage. 56 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ## What it does 4 | 5 | - Click the button in the Git pane to run the extension. 6 | - Reads from the status of your git repo, or staged changes. 7 | - Generates a commit message for you, then you can edit and commit as you like. 8 | 9 | 10 | ## Details 11 | 12 | A roadmap of features and whether done or not. 13 | 14 | - [x] Handle staged and unstaged files flexibly. 15 | - Handle **staged** files if there are any, so you can change a few files and then generate messages for those. But if there are zero staged changes, the extension will fall back to the working tree of unstaged changes. 16 | - Note that **new** files (including when doing a rename) should **always** be staged so that the extension can pick them up and so git can see that two paths for a renamed file are the same file. 17 | - [x] Generate a single-line commit message for a file to be committed, using action verbs (e.g. `Create`, `Update`, `Delete`) 18 | - [x] Handle changes from a single changed file. 19 | - [ ] Handle changes from two or more files. 20 | - [x] As a list of the same nature e.g. `update foo.txt and fizz/bar.txt`, `feat: create foo.txt, fizz/bar.txt and buzz.js` (including prefix) and `Various changes to foo.txt and fizz/bar.txt` (for one updated and one new file). See [#29](https://github.com/MichaelCurrin/auto-commit-msg/pull/29). 21 | - [x] As a count. e.g. `update 3 files`. See [#38](https://github.com/MichaelCurrin/auto-commit-msg/issues/38). 22 | - [ ] As different verb for each change `create foo.txt and delete bar.txt`. See [#37](https://github.com/MichaelCurrin/auto-commit-msg/issues/37) and See [#52](https://github.com/MichaelCurrin/auto-commit-msg/issues/52). 23 | - [ ] As a count in a directory. `update 3 files in foo` 24 | - [ ] As a count with a conventional commit message. See [#51](https://github.com/MichaelCurrin/auto-commit-msg/issues/51). 25 | - [ ] As a count with a label. e.g. `update 3 config files`. See [#13](https://github.com/MichaelCurrin/auto-commit-msg/issues/13). 26 | - [ ] As count that uses the old message. See [#55](https://github.com/MichaelCurrin/auto-commit-msg/issues/55) 27 | - [x] Support using multiple repos in one VS Code window. 28 | - [x] Keep user-entered value as a prefix e.g. Keep `docs:` (or ticket number) so message becomes `docs: Update README.md` 29 | - [x] Use conventional commits e.g. `chore: Update package.json` 30 | - [x] Support directories or filenames with spaces in them by adding quotes. e.g. `chore: rename foo.txt to 'foo bar.txt'` 31 | 32 | ## Topics areas 33 | 34 | It recognizes files from a variety of languages and tooling and then provides an appropriate conventional commit message. 35 | 36 | - [x] Python - package files and configs. 37 | - [x] JavaScript, TypeScript - package files and configs. 38 | - [x] Ruby - package files and configs. 39 | - [x] Go modules. 40 | - [x] Circle CI, GitHub Actions - `ci`. 41 | - [x] Makefile and package files for languages - `build`. 42 | - [x] Config files like YAML, JSON and TOML - `chore` or `build`. 43 | 44 | ### Capabilities 45 | 46 | This extension understands something about a single file and how it changed. 47 | 48 | #### Actions 49 | 50 | Here are supported action words that are used. 51 | 52 | - `Create` 53 | - `Update` 54 | - `Delete` 55 | - `Move` 56 | - `Rename` 57 | - `Move and rename` 58 | 59 | #### Prefixes 60 | 61 | Based on the action and the file (directory, name, and extension), a _conventional commit_ prefix will be derived. If none of the labels can be applied, they be will left out. 62 | 63 | Here are the prefixes it knows, with a summary of the rule used. 64 | 65 | | Prefix | Rule | 66 | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 67 | | `feat` | For a new file. (The tool can't differentiate between content for a feature / refactor / bug fix, but it assumes a new file is probably going to be a new feature) | 68 | | `chore` | For config files and for deleting, renaming, or moving a file. | 69 | | `build` | For package management files and `Makefile` / `Rakefile`. | 70 | | `ci` | For configs around GH Actions, CircleCI and BuildKite. | 71 | | `test` | For directories and files related to tests, like `tests/` or `index.spec.js`. | 72 | | `docs` | For documentation changes, like `README.md`, `docs/` or `CONTRIBUTING.md`. | 73 | 74 | There are other prefixes available. These require looking at the content of the file, not just the path. These are not in the scope of this project, but you can always type them manually. 75 | 76 | - `style` 77 | - `fix` 78 | - `refactor` 79 | - `perf` 80 | -------------------------------------------------------------------------------- /docs/manual/README.md: -------------------------------------------------------------------------------- 1 | # User manual 2 | > Guides for users to install and use the extension 3 | 4 | - [Installation](installation.md) 5 | - Install the extension. 6 | - [Usage](usage.md) 7 | - How to run the extension. It will generate a Conventional Commit type prefix where possible and a description of file changes. 8 | - [Conventional Commits](conventional-commits.md) 9 | - This page explains more about the commit standard used in this extension. 10 | - [Q&A][] 11 | - Ask questions on the Marketplace page about how to use the installed extension or clarification on the [features](/docs/features.md) available. 12 | - [Uninstall](uninstall.md) 13 | - Remove the extension. 14 | 15 | [Q&A]: https://marketplace.visualstudio.com/items?itemName=MichaelCurrin.auto-commit-msg&ssr=false#qna 16 | -------------------------------------------------------------------------------- /docs/manual/conventional-commits.md: -------------------------------------------------------------------------------- 1 | # Conventional Commits 2 | > Info on how the Conventional Commits standard is applied for generating output in this project. 3 | 4 | 5 | ## Resources 6 | 7 | - The official [Conventional Commits](https://www.conventionalcommits.org) homepage 8 | - My [Conventional Commits cheatsheet](https://michaelcurrin.github.io/dev-cheatsheets/cheatsheets/other/conventional-commits.html) in my Dev Cheatsheets projects, covering parts of the standard most relevant to me and this extension project. 9 | 10 | 11 | ## The official standard 12 | 13 | Here is the full syntax for a commit message: 14 | 15 | ``` 16 | [optional scope]: 17 | 18 | [optional body] 19 | 20 | [optional footer] 21 | ``` 22 | 23 | The standard says that description is meant to start with a _lowercase letter_, so this is applied throughout this project. 24 | 25 | 26 | ## What this project uses 27 | 28 | The body and footer are ignored in this project, to keep things simple. Perhaps, one day, some details will be added to the body by this extension. 29 | 30 | So then the format of generated messages: 31 | 32 | - `TYPE: DESCRIPTION` 33 | - `TYPE(SCOPE): DESCRIPTION` 34 | 35 | Additionally, a custom message is allowed: 36 | 37 | - `CUSTOM_MESSAGE TYPE: DESCRIPTION` 38 | 39 | Here are some sample messages that this extension creates: 40 | 41 | - `feat: create foo.txt` 42 | - `build: update Makefile` 43 | - `build(deps): update package-lock.json` 44 | - `docs: update README.md` 45 | - `test: update foo.spec.js` 46 | - `chore: rename fizz.txt to buzz.txt` 47 | 48 | See [Sample usage](https://github.com/MichaelCurrin/auto-commit-msg#sample-usage) for screenshots. 49 | -------------------------------------------------------------------------------- /docs/manual/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Install from the Marketplace 4 | 5 | ### Marketplace website 6 | 7 | Go to the Marketplace link below then click the _Install_ button. 8 | 9 |
10 | 11 | [![VS Code Marketplace - Auto Commit Message](https://img.shields.io/badge/VS_Code_Marketplace-Auto_Commit_Message-2ea44f?style=for-the-badge&logo=visual-studio-code)](https://marketplace.visualstudio.com/items?itemName=MichaelCurrin.auto-commit-msg) 12 | 13 |
14 | 15 | ### Within VS Code 16 | 17 | 1. Go to VS Code's Extensions side panel. 18 | 1. Search for and install the extension by name - _Auto Commit Message_. 19 | 1. Click _Install_. 20 | 21 | Or enter the following in the VS Code command prompt: 22 | 23 | ```sh 24 | $ ext install MichaelCurrin.auto-commit-msg 25 | ``` 26 | 27 | --- 28 | 29 | Continue to the [Usage](usage.md) doc. 30 | 31 | 32 | ## Manual install approaches 33 | 34 | ### Use the GUI 35 | 36 | 1. In the browser. 37 | 1. Go to the GitHub Releases page. 38 | - [![view - releases](https://img.shields.io/badge/view-releases-2ea44f?style=for-the-badge&logo=github)](https://github.com/MichaelCurrin/auto-commit-msg/releases) 39 | 1. Find the latest release. 40 | 1. Expand the _Assets_ section. 41 | 1. Download a copy of the `.vsix` archive file by clicking on the filename. 42 | 1. In VS Code 43 | 1. Open the Command Palette (_View_ then _Command Palette_). 44 | 1. Type `extension`, wait for the auto-complete, then select _Extension: Install from VSIX..._. 45 | 1. Select the file downloaded earlier.f 46 | 1. Make sure to **restart** VS Code to get the extension loaded. 47 | 48 | ### Use the terminal 49 | 50 | Instructions for macOS / Linux. 51 | 52 | 1. Identify a release number on the GitHub Releases page. 53 | - [![view - releases](https://img.shields.io/badge/view-releases-2ea44f?style=for-the-badge&logo=github)](https://github.com/MichaelCurrin/auto-commit-msg/releases) 54 | 1. Download the `.vsix` extension file using `curl`, using the appropriate target version. 55 | ```sh 56 | $ cd ~/Downloads 57 | $ TARGET='0.19.0' 58 | $ curl -L -O "https://github.com/MichaelCurrin/auto-commit-msg/releases/download/v$TARGET/auto-commit-msg-$TARGET.vsix" 59 | ``` 60 | 1. Install the extension. Here we run VS Code in the CLI against a path to the downloaded file (no need to unzip it first). 61 | ```sh 62 | $ code --install-extension PATH 63 | ``` 64 | e.g. 65 | ```sh 66 | $ code --install-extension ~/Downloads/auto-commit-msg-0.19.0.vsix 67 | ``` 68 | ``` 69 | Installing extensions... 70 | Extension 'auto-commit-msg-0.19.0.vsix' was successfully installed. 71 | ``` 72 | 1. If you have VS Code running already, make sure to **restart** it to get the extension loaded. 73 | -------------------------------------------------------------------------------- /docs/manual/uninstall.md: -------------------------------------------------------------------------------- 1 | # Uninstall 2 | > How to uninstall the extension if you don't want it anymore 3 | 4 | 5 | ## Extension pane 6 | 7 | 1. Open the _Extensions_ tab in VS Code. 8 | 1. Click the Auto Commit Message extension. 9 | 1. Click _uninstall_. 10 | 1. Restart VS Code. 11 | 12 | Or 13 | 14 | ## Command-prompt 15 | 16 | 1. Run this command. 17 | ```sh 18 | $ code --uninstall-extension MichaelCurrin.auto-commit-msg 19 | ``` 20 | 2. Restart VS Code. 21 | -------------------------------------------------------------------------------- /docs/manual/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | > How to use extension after it has been installed 3 | 4 | 5 | 6 | With the extension installed, open VS Code at the root of a git repo. 7 | 8 | 25 | 26 | 1. Open VS Code. 27 | 2. Go to the Source Control (Git Extension) tab. You'll see this extension added. 28 | 3. Make a change to a file and use the Source Control pane to stage a file. 29 | 4. Click the extension button. 30 | 5. You'll see a message added the commit message box. Edit it if you want. 31 | 6. Commit with the VS Code UI. e.g. Use CTRL+ENTER or press the tick button. 32 | 33 | To avoid having to use the extension with the mouse, you can use the command bar: 34 | 35 | 1. Press CTRL+SHIFT+P to open it up, select _Auto Commit Message_ and press enter or click it. 36 | 1. Your cursor will move to the commit message box, so now you can press commit with the keyboard. Use CTRL+ENTER. 37 | -------------------------------------------------------------------------------- /docs/other/README.md: -------------------------------------------------------------------------------- 1 | # Other 2 | -------------------------------------------------------------------------------- /docs/other/ai-tools.md: -------------------------------------------------------------------------------- 1 | # AI tools 2 | > Using AI to generate commit messages 3 | 4 | ## AI-based extensions 5 | 6 | Search for "gpt commit" in the extensions marketplace and you'll see plenty of extensions that use GPT to write your commit message. 7 | 8 | e.g. [GPT Commit](https://marketplace.visualstudio.com/items?itemName=DmytroBaida.gpt-commit) 9 | 10 | If done well, this could be even more flexible, thorough and natural than this Auto Commit Msg extension which has no AI (but at least handles basic messages for a variety of cases based on the paths of the files that changed rather than the contents). 11 | 12 | You can also try [Codeium](https://codeium.com/download) extension which has a lot of AI chat and completion features and includes a commit message generator (but it always gives me an error). 13 | 14 | ### Downsides 15 | 16 | - There's a time delay. 17 | - Requires network access. 18 | - Your code is shared across the internet with OpenAI etc., which can cause issues for private and company projects. 19 | - You need to a ChatGPT API key and to pay for a premium subscription. 20 | - Limited acccuracy - I don't know if GPT is powerful enough to figure out the context of what you are doing and intend to write based on a diff only, as some of the reasoning for a change won't be covered by code itself but by real world events and requirements. You can pass more unchanged files from you codebase in an extension to the AI but this costs more money and won't always help. 21 | 22 | ## Local LLMs 23 | 24 | To get around the limitations of the above, especially for private codebases, you could use a locally run LLM (like Ollama) instead of ChatGPT. Perhaps there are extensions which support this. 25 | -------------------------------------------------------------------------------- /docs/other/commit-philosophy.md: -------------------------------------------------------------------------------- 1 | # Commit philosophy 2 | 3 | This repo is based on the practice of small, frequent, atomic commits such that it easy to rollback to a work state when developing (such as stashing uncommitted changes), or rolling back a deploy to an earlier commit (or using a revert commit). 4 | 5 | If there is too much friction in creating a commit message, then I find myself putting off a few changes and then I have 5 files changes for different reasons and sometimes multiple tangle changes within each file. 6 | 7 | So it makes sense for my approach to have a magical way to generate a commit message for a change in one or two files, commit and then keep going. Which is where this extension comes in. 8 | 9 | Yes sometimes you need to rename a variable across 10 files and then a manually-typed message makes sense. Or you add a function and use it at 3 levels, or you fix the same bug in multiple places. Then please go ahead and don't use the pre-filled message. 10 | 11 | But if your commit style is somewhat like mine, you'll find that 80% of he commit messages you write can be generated programmatically, especially for one file or one line changes. This keeps your commit flow smooth and keeps you in a state of writing code and solving problems, not feeling interrupted to write meaningful messages or type out precise file names. 12 | 13 | Also, the atomic approach doesn't have to be followed strictly. Sometimes I add tests and functions in one commit. Sometimes tests first and functions in a later commit to get the tests to pass. 14 | 15 | Tip: Even if you don't use the full message generated by this extension, you can still just keep the path at the end as part of your message. 16 | -------------------------------------------------------------------------------- /docs/other/credit.md: -------------------------------------------------------------------------------- 1 | # Credit 2 | > Giving credit to projects which influenced this project 3 | 4 | 5 | The core of this project's VS Code extension logic is about creating a commit message and pushing it to the _Git Extension_ input box in the UI. That comes from the _Git Prefix_ extension. The use of _Git CLI_ in the extension comes from the _Semantic Git Commit_ extension. 6 | 7 | See more info below. 8 | 9 | 10 | ## Hello World sample 11 | 12 | - Source: [Hello World test sample](https://github.com/microsoft/vscode-extension-samples/tree/master/helloworld-test-sample) project 13 | - My project started off as an extension based on this VS Code test sample. It was just hello world and didn't help with my flow, so I got rid of the code in later tags. 14 | 15 | 16 | ## Git Semantic Commit 17 | 18 | Note that a "semantic commit" message is the same as a "conventional commit" message. 19 | 20 | - Repo: [nitayneeman/vscode-git-semantic-commit](https://github.com/nitayneeman/vscode-git-semantic-commit) 21 | - I liked how this extension does Git CLI commands, so I used the original [Git](https://github.com/nitayneeman/vscode-git-semantic-commit/blob/master/src/git.ts) class and `getWorkspaceFolder` function. That served as the base for my functionality in [gitCommands.ts](/src/gitCommands.ts), which allowed my `extension.ts` script to work as I wanted. I later split out the `Git` class into functions as that made more sense. 22 | - The rest of the extension was too advanced for what I needed to do, so I ended up not using the other parts. 23 | - I like the integration tests approach though so I might come back to use pieces of that. 24 | 25 | 26 | ## Git Prefix 27 | 28 | - Repos: [srmeyers/git-prefix](https://github.com/srmeyers/git-prefix) or the fork [d3skdev/git-prefix](https://github.com/d3skdev/git-prefix). 29 | - I found this in the marketplace - it adds a _branch prefix_ to the commit message UI box and gives the user a chance to read it and edit it. This is very close to the flow that I want and it is far less code than Git Semantic Commit, so my extension is based on this. See for example the use of `repository.inputBox.value` in [extension.ts](/src/extension.ts). 30 | 31 | 32 | ## Parse Git Status 33 | 34 | - Repo: [jamestalmage/parse-git-status](https://github.com/jamestalmage/parse-git-status) 35 | - I started out parsing git status output (not diff-index), intending to use this NPM package. Unfortunately, it does not come with types and I couldn't figure out how to add types, so I took the logic from it and rewrote it as my own so it is easier to manage and extend. See my [parse-git-output](/src/generate/parse-git-output) module - that based on `parse-git-status`. 36 | - My enhancements: 37 | - Compatible with git status `--porcelain` flag. 38 | - Replaced use of `-z` mode. As I do not like separating by either one _or_ two null characters and how the original package did this splitting. So I split columns by whitespace rather and had lines split by a newline character (for when more than one files changes or there is a trailing line). 39 | - Cleaner `for` loop logic 40 | - I found the original hard to work on because of how it uses an old-style `for` loop and `i` variable. 41 | - I found using `.split` with a regex pattern was much simpler. 42 | - Add TS types (essential for my project to run). 43 | - Add `git diff-index` support - see [status vs diff-index](/docs/development/status-vs-diff-index.md) doc. 44 | -------------------------------------------------------------------------------- /docs/other/functionality.md: -------------------------------------------------------------------------------- 1 | # Functionality 2 | 3 | See the [gitCommands.ts](/src/gitCommands.ts) script for implementation details. 4 | 5 | UPDATE: Perhaps using untracked is a good idea - although not in the git CLI flow, in the VS Code git integration, new/untracked files would be added when adding _everything_ when nothing is staged. So updates might be needed below. Also, see the use of `git status -u` and similar for `diff-index`, but note that `diff-index` is still preferred because of the choice of staged or not while `status` does not have that ability. 6 | 7 | - The extension button must be able to run against **staged** changes only (if any). This will be the most common flow for the initial easy functionality of only committing one file at a time. 8 | - What is staged only. Not untracked. 9 | - Command 10 | - Use the output from `git diff-index --name-status --cached HEAD`. That is staged but not untracked. This was based on [index.js](https://github.com/mcwhittemore/staged-git-files/blob/master/index.js) of another extension. 11 | - Note `git diff` will not be appropriate here. 12 | - And fallback to **all** changes that would be committed. Nothing is staged then, this is everything, but excluding untracked. (There may be specific behavior here I've assumed because of my smart commit or other VS Code preferences.) 13 | - Want both staged and unstaged. But not untracked. The downside is that renames won't get picked unless they are staged (since the new file appears untracked). 14 | - Command 15 | - Use the output from `git status -s -uno --porcelain`. 16 | - Use the output from `git diff-index --name-status HEAD`. 17 | 18 | Also of interest, to get a summary of changes: 19 | 20 | ```sh 21 | $ git diff-index --shortstat HEAD 22 | 4 files changed, 131 insertions(+), 96 deletions(-) 23 | ``` 24 | 25 | See more [here](https://github.com/MichaelCurrin/dev-cheatsheets/blob/master/cheatsheets/git/commands/diff-index.md). 26 | -------------------------------------------------------------------------------- /docs/other/generate-message.md: -------------------------------------------------------------------------------- 1 | # Generate message 2 | 3 | This tool aims to prepare a smart commit message for you at the point you commit. Unfortunately, VS Code does not work with git hooks so this flow only works when committing on the command-line. 4 | 5 | This is to take the effort out of writing self-explanatory commits, where the machine can write a message I would write for a simple case. And where looking at the diff output is sufficient to see the _why_. 6 | 7 | This tool does not require you to always use the auto-generated message - it simply makes it available if you enable it. If there is something that needs to be captured like that a commit is a fix or the reason for a fix or a high-level description of the changes made (like a renamed variable), then write the message by hand. 8 | -------------------------------------------------------------------------------- /docs/other/plan.md: -------------------------------------------------------------------------------- 1 | # Project plan 2 | 3 | This project is a work in progress. It is starting out as a specification of the desired behavior on the [Wiki](https://github.com/MichaelCurrin/auto-commit-msg/wiki), then will tests added and then only the functionality last. 4 | 5 | This will probably be in Python for easy scaling and tests. And it will probably use git commit hooks - whatever I find works well for command-line use and also VS Code messages if left blank but allowing manual overrides. And ideally showing the message just before its made so one can confirm. But this may be reaching too much especially for two entry methods. Maybe it can be generated when files are staged based on an event in VS Code. 6 | 7 | 8 | ## Tasks 9 | 10 | Roadmap of things to do to get this to v1 release. 11 | 12 | - [x] Works with `git` repos. 13 | - [x] Tests - Unit tests that are run with GitHub Actions CI 14 | - [x] Test coverage report. 15 | - [ ] Update logo. 16 | - [x] Available in VS Code marketplace. 17 | - [ ] CI to build the package archive on tag. 18 | - [ ] Clean up docs and Wiki. 19 | 20 | See [Issues](https://github.com/MichaelCurrin/auto-commit-msg/issues) on the repo. 21 | -------------------------------------------------------------------------------- /docs/other/purpose-1.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | 3 | 4 | ## Why? 5 | 6 | Yes, before you ask, yes I _know_ that meaningful commit messages are meaningful to future me and other developers in understanding intent or the reason for a change. 7 | 8 | But I also find that the majority of commits I make have messages that could be generated programmatically. Based on the _pareto_ principle, probably 80% of them. The messages are easy to compute, or the change is so small (a few lines changed) that it does not have much meaning on its own until the "feature" or "fix" is complete through many commits (formalized with a Pull Request or tag). And sometimes I am working on a small personal project or writing docs (which are not code), so it matters to me more than I can commit frequently than every message is meaningful and handwritten. 9 | 10 | Additionally, there are some messages which are easy to figure out and write using an algorithm and not so nice to type precisely. e.g. `Update 5 files and created 2 files`. Or `Created 2 files in foo/bar`. This can be prefaced with a manual "fix: " or "feat: " to make the intent clear. 11 | 12 | I do a lot of frequent, small commits to store my working changes - they are "atomic", at least when I make sure the code is working at each commit. That means that a lot of my commits are small incremental changes around adding or fixing code or docs, or renaming or moving files. There is friction to thinking of and typing the message - so when I am stressed or tired I might end up taking unwanted shortcuts. Like committing less often (then it's harder to figure out what broke and what change to undo). Or I write shorter and simple messages. `Add heading to README.md` becomes `Add heading` or `Update README`. 13 | 14 | Occasionally there is a commit, which needs explaining because it is a significant change in how things work, or I want to elegant capture why a lot of files changed for the same or a similar reason. So I like to write a descriptive message then. And the idea with this tool is that you can go always override the auto-generated message with your own (much like Github's default commit message). 15 | 16 | 17 | ## Other auto commit tools 18 | 19 | ### Github 20 | 21 | This tool is inspired by Github's own default commit messages - the grey text which is used if you don't enter a commit message. 22 | 23 | e.g. 24 | 25 | ``` 26 | Created foo.txt 27 | Renamed foo.txt to bar.txt 28 | Updated bar.txt 29 | ``` 30 | 31 | But while Github only lets you change one file at a time and generate a message based on that, this tool is smart enough to generate a message based on all the files to be committed. 32 | 33 | Also note that wisdom I read online about commit messages says that a commit should be imperative and not past tense, which also makes more sense when reverting a commit. 34 | 35 | e.g. 36 | 37 | ``` 38 | Create foo.txt 39 | ``` 40 | 41 | ### StackEdit 42 | 43 | - [Stackedit.io](https://stackedit.io) 44 | 45 | This tool lets you edit markdown files with a preview sidebar. And it automatically saves your files every few minutes, reducing friction. 46 | 47 | It generates a message that includes StackEdit in the name. e.g. 48 | 49 | ``` 50 | README.md updated from https://stackedit.io/ 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/other/purpose-2.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | 3 | This is a VS Code extension - when you run it, it will look at files changed and then generate a commit message for you and add it to the commit message box (using the Git Extension's UI). 4 | 5 | It will look at files that are staged. If there are none, then it will look at all changed unstaged files instead. The result will be a simple, descriptive message that fits on one line. 6 | 7 | Note: At the moment a message can be generated based on one changed file. 8 | 9 | The idea of this tool is to take the friction out of writing a commit message so that you commit more frequently (such as with more one-line changes rather than mixing multiple unrelated changes together) and to save having to type out details that can be automatic or a tedious (mentioning long or difficult-to-type paths or filenames). This tool is not meant to be perfect - it gives a best guess for common cases. 10 | 11 | It is also not meant to replace writing messages by hand. It is a tool for myself mainly - 80% of my commit messages could have been written by an algorithm. 12 | 13 | And for the other 20% when the change is important to describe in detail or hard to figure out programmatically (like class renames or bug fix descriptions), then I can still write my manual commit message. 14 | 15 | This tool was inspired partly by GitHub's UI - it suggests a message in grey like "Update README.md" when I edit that file and if I enter nothing it uses that. 16 | 17 | There are many tools out there that will _lint_ your commit message, or provide you a multi-line template, or will insert something in it like "feat:" or an emoji. But AutoCommitMsg writes you entire commit message for you in one line. 18 | 19 | 28 | -------------------------------------------------------------------------------- /docs/other/reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | See my [git hooks](https://github.com/MichaelCurrin/dev-cheatsheets/blob/master/cheatsheets/git/hooks.md) reference. 4 | 5 | 6 | ## Prepare a commit message 7 | 8 | In `prepare-commit-msg` hook, assuming it is a Bash script. 9 | 10 | You can use echo/print lines to print out - this would be printed 11 | on a successful or failed git commit. 12 | If you want to actually write over the message you need to use the path to the commit message temp file. 13 | 14 | e.g. 15 | 16 | ```sh 17 | COMMIT_MSG_FILE=$1 18 | echo -n "$CHG" >$COMMIT_MSG_FILE 19 | ``` 20 | 21 | Note writing of multi-line output to the file is needed like above. 22 | 23 | The file path will be like: 24 | 25 | ``` 26 | $HOME/path/to/repo/.git/COMMIT_EDITMSG 27 | ``` 28 | 29 | ## git status 30 | 31 | This will ignore untracked files and have machine-readable output (without color). 32 | 33 | ```sh 34 | CHG=$(git status -s -uno --porcelain) 35 | ``` 36 | 37 | See more in my dev cheatsheets. 38 | 39 | 40 | ### Warning 41 | 42 | Note a weakness - if you rename a file and then modify it, then you can have A and M for one file. 43 | 44 | ```sh 45 | $ git status -s 46 | D src/test/main.test.ts 47 | AM src/test/single-file.test.ts 48 | ``` 49 | 50 | But when adding first using CLI or UI, then it will be simplified. 51 | 52 | If the percent change is very small, then it will be converted to a rename. 53 | 54 | In this case, the files are different enough to not be collapsed as a rename. Note that no code was changed - the add command changes from above. 55 | 56 | ```sh 57 | $ git add -A 58 | $ git status -s 59 | D src/test/main.test.ts 60 | A src/test/single-file.test.ts 61 | ``` 62 | 63 | Fortunately, this status will always run _after_ staging because of the hook flow (or extension use). 64 | -------------------------------------------------------------------------------- /docs/other/terminal-hook.md: -------------------------------------------------------------------------------- 1 | # Terminal hook 2 | > Setup and use a prepare-commit-msg hook for terminal commits 3 | 4 | **Note** Using this script outside of the extension is on the roadmap but not done yet. 5 | 6 | You can use the core logic of this project _without_ using the VS Code Git extension pane. You can use it in a standalone terminal or another IDE's integrated terminal. 7 | 8 | 9 | ## Warning 10 | 11 | While VS Code does in fact honor a `prepare-commit-msg` hook, unfortunately 12 | 13 | - The hook **cannot** read from the UI message box and 14 | - The message is _always_ written out without confirmation. 15 | 16 | So use an enable commit hook message hook inside VS Code with caution, but outside VS Code you are safe. 17 | 18 | ### Alias 19 | 20 | Another way around the issue in VS Code, is instead of using a commit hook, rather use an **alias** command like `git c` - see the [Shell README](/shell/README.md) for alias instructions. That could run the Auto Commit Msg script and pass the result to `git commit -m '...'`. Also, a hook only works when set up in a project, while an alias can work anywhere. 21 | 22 | 23 | ## Reference 24 | 25 | Here is the flow for using any `prepare-commit-msg` hook, not specific to this project: 26 | 27 | 1. Edit a file in your project. 28 | 1. Commit on command-line. 29 | 1. Prepare a commit message (this internally can look for the template as per the sample). 30 | 1. Override value with own message if desired. 31 | 1. Exit message editor view. 32 | 1. Commit is made. 33 | 1. Hook runs and the user sees a prepared message. 34 | 35 | 36 | ## Installation 37 | 38 | Note that, in the future, the installation steps here will change and be split into maybe a separate installation doc. One part is installing the hook in any repo using a file built with GH actions. Another is to build and use the file by hand, which is simpler to set up and will be become a development doc step once actions have been set up. 39 | 40 | ### Install system dependencies 41 | 42 | Install Node.js - see [instructions](https://gist.github.com/MichaelCurrin/aa1fc56419a355972b96bce23f3bccba). 43 | 44 | ### Clone 45 | 46 | ```sh 47 | $ git clone git@github.com:MichaelCurrin/auto-commit-msg.git 48 | $ cd auto-commit-msg 49 | ``` 50 | 51 | ### Install hook 52 | 53 | Notes: 54 | 55 | - This flow covers how to add an executable script as a **git hook** in a project. This project was initially built around the Bash scripts in [shell](/shell/) as a proof of concept and you can continue to use one of those. Also, there was an idea to turn this TypeScript code from VS Code extension and re-purpose it as a standalone git hook for terminal use - but that doesn't work nicely in VS Code because a message hook ignores what you type in the message box and uses the hook's logic only. 56 | - This is very general for now - there is no Node or compile build step. 57 | 58 | For working on this project, set up a symlink from a hook in the repo to exist in `.git/hooks` directory. Adjust the first script name and path as needed but the last value must stay as is to be recognized by git. 59 | 60 | ```sh 61 | $ (cd .git/hooks && ln -s -f ../../prepare-commit-msg prepare-commit-msg) 62 | ``` 63 | 64 | Use `-f` to overwrite the existing file. 65 | 66 | A `-r` relative flag would make this shorter but it is not standard across Linux and Bash. 67 | 68 | Check it: 69 | 70 | ```sh 71 | $ ls -l .git/hooks 72 | ``` 73 | 74 | For other projects using this repo's hook - use `cp` or `curl` to add the hook to your repo. Or package and an installable Zip. 75 | 76 | Instructions to follow as this develops. 77 | 78 | 79 | ## Disable 80 | 81 | Remove the hook: 82 | 83 | ```sh 84 | $ rm .git/hooks/prepare-commit-msg 85 | ``` 86 | 87 | 88 | ## Usage 89 | 90 | Perform a commit in the terminal. You'll see the prepared message appear. 91 | 92 | ```sh 93 | $ git commit 94 | $ git log 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/other/unused.md: -------------------------------------------------------------------------------- 1 | # Unused 2 | 3 | Code that is no longer needed because approach changed, but kept in case it is needed again. 4 | 5 | 6 | ## Status 7 | 8 | These were for when the `git status` approach was used. Before the `git diff-index` approach was used. 9 | 10 | - `src/generate/action.ts` 11 | ```typescript 12 | /** 13 | * Extract single action from given X and Y actions. 14 | * 15 | * "Modified" must take preference over the others. Unfortunately, there is no way here to combine 16 | * update and move. 17 | */ 18 | function _lookupStatusAction(x: string, y: string): string { 19 | if (ACTION[y as ActionKeys] === ACTION.M) { 20 | return ACTION.M; 21 | } 22 | 23 | return ACTION[x as ActionKeys]; 24 | } 25 | ``` 26 | - `src/git/cli.ts` 27 | ```typescript 28 | /** 29 | * Run `git status` with flags and return output. 30 | * 31 | * This will ignore untracked and remove color. 32 | */ 33 | async function status(options: string[] = []) { 34 | return execute(getWorkspaceFolder(), "status", [ 35 | "--short", 36 | "-uno", 37 | "--porcelain", 38 | ...options, 39 | ]); 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | How to install and run the extension in VS Code. 4 | 5 | 6 | ## Install 7 | 8 | Go to the Marketplace link below, then click the _Install_ button. 9 | 10 |
11 | 12 | [![VS Code Marketplace - Auto Commit Message](https://img.shields.io/badge/VS_Code_Marketplace-Auto_Commit_Message-2ea44f?style=for-the-badge&logo=visual-studio-code)](https://marketplace.visualstudio.com/items?itemName=MichaelCurrin.auto-commit-msg) 13 | 14 |
15 | 16 | 17 | ## Run 18 | 19 | Use the extension like this: 20 | 21 | 1. Open VS Code. 22 | 1. Open a project which is a Git repo. 23 | 1. Edit one or more files and save them. 24 | 1. Optionally stage one of the files. 25 | - This is useful if want smaller changes in your commit and your commit message. 26 | - Staging is necessary if you do a _move_ or _rename_ action, so that Git sees the old and new path as the _same_ file. But otherwise, you don't have to stage. 27 | 1. In VS Code's built-in Git pane, click the Auto Commit Message icon. 28 | 1. The extension will create a descriptive commit message for you in the commit message box. 29 | 1. Optionally edit your message. 30 | 1. Commit - click the tick symbol button. Or Control+Enter or CMD+Enter. 31 | -------------------------------------------------------------------------------- /hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | git pull --rebase || git pull --rebase origin master 4 | make all 5 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelCurrin/auto-commit-msg/3b88dc49e80d07fa58784fe2f4196a4054eb2977/images/icon.png -------------------------------------------------------------------------------- /images/message-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /images/message.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auto-commit-msg", 3 | "type": "commonjs", 4 | "version": "0.27.2", 5 | "scripts": { 6 | "fmt:check": "prettier -c src", 7 | "fmt:fix": "prettier -w src", 8 | "lint:check": "eslint . --ext .ts --max-warnings 10", 9 | "lint:fix": "eslint . --ext .ts --fix", 10 | "clean": "rm -rf out", 11 | "watch": "tsc -watch -p .", 12 | "compile": "npm run clean && tsc -p .", 13 | "pretest": "npm run compile", 14 | "test:unit": "cd out && mocha --recursive", 15 | "test": "npm run test:unit", 16 | "cover": "nyc npm test", 17 | "cover:report": "nyc report --reporter=lcov", 18 | "cover:check": "nyc check-coverage --lines 95", 19 | "checks": "npm run fmt:check && npm run lint:check && npm test", 20 | "vscode:prepublish": "npm run compile", 21 | "build": "mkdir -p build && vsce package --out build/", 22 | "ext": "npm run build && code --install-extension $(ls -t build/* | head -n1) --force", 23 | "preversion": "npm run checks", 24 | "version": "npm run build", 25 | "postversion": "git push --follow-tags", 26 | "sb": "bin/reset_sandbox.sh" 27 | }, 28 | "engines": { 29 | "node": ">=22", 30 | "npm": ">=10", 31 | "vscode": "^1.92.0" 32 | }, 33 | "devDependencies": { 34 | "@types/mocha": "^10.0.3", 35 | "@types/node": "^22.4.1", 36 | "@types/vscode": "^1.92.0", 37 | "@typescript-eslint/eslint-plugin": "^8.12.2", 38 | "@typescript-eslint/parser": "^8.12.2", 39 | "@vscode/vsce": "^3.0.0", 40 | "eslint": "^8.57.1", 41 | "mocha": "^10.0.0", 42 | "nyc": "^17.1.0", 43 | "prettier": "^3.0.3", 44 | "source-map-support": "^0.5.20", 45 | "typescript": "^5.6.3", 46 | "vscode-test": "^1.6.1" 47 | }, 48 | "displayName": "Auto Commit Message", 49 | "description": "A VS Code extension to generate a smart commit message based on file changes", 50 | "publisher": "MichaelCurrin", 51 | "author": { 52 | "name": "MichaelCurrin" 53 | }, 54 | "license": "MIT", 55 | "homepage": "https://github.com/MichaelCurrin/auto-commit-msg#readme", 56 | "bugs": "https://github.com/MichaelCurrin/auto-commit-msg/issues", 57 | "repository": { 58 | "type": "git", 59 | "url": "https://github.com/MichaelCurrin/auto-commit-msg" 60 | }, 61 | "keywords": [ 62 | "git", 63 | "auto", 64 | "generate", 65 | "commit", 66 | "message", 67 | "conventional-commit", 68 | "vs-code", 69 | "vscode", 70 | "productivity" 71 | ], 72 | "main": "out/extension.js", 73 | "categories": [ 74 | "SCM Providers" 75 | ], 76 | "icon": "images/icon.png", 77 | "galleryBanner": { 78 | "color": "#f0efe7", 79 | "theme": "light" 80 | }, 81 | "contributes": { 82 | "commands": [ 83 | { 84 | "command": "commitMsg.autofill", 85 | "title": "Auto Commit Message", 86 | "icon": { 87 | "dark": "images/message.svg", 88 | "light": "images/message-light.svg" 89 | } 90 | } 91 | ], 92 | "menus": { 93 | "scm/title": [ 94 | { 95 | "command": "commitMsg.autofill", 96 | "when": "scmProvider == git", 97 | "group": "navigation" 98 | } 99 | ] 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /shell/README.md: -------------------------------------------------------------------------------- 1 | # Shell 2 | 3 | Bash scripts around this project. 4 | 5 | All the scripts in this directory are named with dashes and not underscores, to match the Git hook filenames convention. 6 | 7 | 8 | ## Samples 9 | 10 | Archive of shell scripts for reference. These are used in the early development of the project but are not actively used. 11 | 12 | - [count-files.sh](count-files.sh) 13 | - [sample.sh](sample.sh) 14 | - [simple-hook.sh](simple-hook.sh) 15 | 16 | 17 | ## Autofill scripts 18 | 19 | These are shell scripts to integrate with the JS scripts in this project, as an alternative to using, VS Code so I can use it any terminal and in other IDEs with their terminals. And if I stop using VS Code completely I can keep using the core at least in a terminal. 20 | 21 | - [autofill-hook.sh](autofill-hook.sh) 22 | - [autofill.sh](autofill.sh) 23 | 24 | They are not complete but work as a POC for using the core logic outside project outside of VS Code as Git hook. 25 | 26 | ### Dev notes 27 | 28 | Remember to **compile** the TS to JS before running this script to get the latest changes. Or use a pre-compiled script. 29 | 30 | #### Purpose 31 | 32 | This script should be used as an **alternative** to using VS Code itself to handle your commit messages, as VS Code does not support a hook properly when going through the UI box (it actually **ignores** any message you type in and uses its own generated message from the hook). 33 | 34 | But, if you don't use it as an actual hook, there is an alternative flow that doesn't mess with VS Code. You can use the other script and set up a Git alias (which can be used across projects without setting a hook even). 35 | 36 | Sample output: 37 | 38 | ```console 39 | $ ./shell/autofill.sh 40 | chore: update settings.json 41 | $ ./shell/autofill.sh 42 | update 11 files 43 | ``` 44 | 45 | Use it with Git. This uses the tool to generate a message and pass it as the Git commit message, but forcing edit mode so you can override it. 46 | 47 | ```sh 48 | $ git commit --edit -m "$(shell/autofill.sh)" 49 | ``` 50 | 51 | Move the script to a `bin` executables directory so you can run it from anywhere. 52 | 53 | ```sh 54 | $ cp autofill.sh /usr/local/bin 55 | ``` 56 | 57 | TODO: 58 | 59 | - [] Where to put the Node script so it can reference it. 60 | - [] Windows support 61 | - [] How to automated the install process for upgrades. Maybe the JS + shell script as NPM package or at least on GitHub with cURL install. 62 | - [] Figure out how to switch between staged and not, with `--cached`. Like passing a param to the shell script and having two aliases. Or to have it as pass of the shell script to fallback to all if anything is staged. Or just control with filenames e.g. `git c .` or `git c package*` - oh wait, the shell script doesn't look at what is passed to `git commit`, only what is staged or not. 63 | 64 | #### Alias 65 | 66 | Set this up in git config aliases as `c` or something. If this was in a _bin_ directory, or used with an absolute path to the script. 67 | 68 | ```toml 69 | [alias] 70 | c = '! git commit --edit -m "$(autofill.sh)"' 71 | ``` 72 | 73 | Then instead of `git commit`, you can do: 74 | 75 | ```sh 76 | $ git c 77 | 78 | $ git c foo.txt 79 | ``` 80 | 81 | #### TODO 82 | 83 | - [] For now this points to the output directory so it limited in real world use. This is a stepping 84 | stone. But ideally the JS files can be copied outside of this project to a central location (maybe 85 | with a `bin` entry point). And the SH script can be added to an individual project in `.git/hooks` dir as `prepare-commit-msg`. 86 | - [] When using this as a hook, consider reading from the **existing** commit message file in the case 87 | of template, so it that can be passed on. 88 | - [] Add a flag for staged to get `--cached` flag. 89 | -------------------------------------------------------------------------------- /shell/autofill-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # CLI test for autofill hook. 3 | # 4 | # This script is *not* ready to be used in other projects. See shell/README.md 5 | # for dev notes. 6 | # 7 | # This is a pure CLIhat script that bypasses using VS Code or an extension. 8 | # It will get output from git, send it to Node CLI entry-point tool and print 9 | # it. This can be used as part of Git commit message hook flow. 10 | # 11 | # Optionally add a `-d` debug flag to print without writing to a file. This 12 | # makes it easy to debug the script outside a Git commit hook flow. 13 | # ./autofill-hook.sh -p 14 | # 15 | # See shell/README.md doc. 16 | set -e 17 | 18 | COMMIT_MSG_FILE=$1 19 | COMMIT_SOURCE=$2 20 | 21 | echo 'Input values' 22 | echo "COMMIT_MSG_FILE = $COMMIT_MSG_FILE" 23 | # Either 'message' or 'template' 24 | echo "COMMIT_SOURCE = $COMMIT_SOURCE" 25 | 26 | # TODO: Test - this conditional is untested. 27 | if [ "$COMMIT_SOURCE" = 'template']; then 28 | echo "Current commit message" 29 | <"$COMMIT_MSG_FILE" 30 | fi 31 | 32 | CHANGES=$(git diff-index --name-status HEAD) 33 | MESSAGE=$(node out/cli.js "$CHANGES") 34 | 35 | if [ "$1" = '-p' ]; then 36 | echo "$MESSAGE" 37 | else 38 | echo "$MESSAGE" >$COMMIT_MSG_FILE 39 | fi 40 | -------------------------------------------------------------------------------- /shell/autofill.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Autofill script. 3 | # 4 | # Produce a commit message from changed files and print to stdout. 5 | # 6 | # This works similar to a Git hook, but is intended to be used alone or in a Git 7 | # alias. 8 | # 9 | # See `shell/README.md` doc. 10 | # See `src/git/cli.ts` for details on flags. Note `--cached` can be added 11 | # if you want to use staged changes only. 12 | set -e 13 | 14 | DIFF_FLAGS='--name-status --find-renames --find-copies --no-color' 15 | CHANGES=$(git diff-index $DIFF_FLAGS HEAD) 16 | 17 | # TODO: Make this a global bin path and a bundled file. 18 | MESSAGE=$(node out/cli.js "$CHANGES") 19 | echo "$MESSAGE" 20 | -------------------------------------------------------------------------------- /shell/count-files.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Proof of concept script to use Bash to produce report of file counts from git 3 | # status. 4 | # 5 | # Run this as a standalone report, not for use as a hook. 6 | # If you use this as a hook, it will print output in the console only and 7 | # not add to commit message. 8 | # 9 | # See simple-hook.sh script for how CHANGES would get written out to a commit 10 | # message file. 11 | # 12 | # This script will get count of files added, modified, deleted and renamed/moved 13 | # and output it. Note that modified and renamed/moved can both apply to the same 14 | # file. 15 | # This does not support 'C' (copied). 16 | # 17 | # This report uses git status and passes it do the steps below to parse it. 18 | # A more flexible approach (for unit tests for example) would be for this 19 | # script to handle the status and call another script to do the parsing and 20 | # result. Then the script could be used separate from git and so accept a 21 | # filename or stdin. 22 | 23 | # Status summary excluding untracked files using a flag. 24 | CHANGES=$(git status -s -uno --porcelain) 25 | 26 | # Note quotes AND -n to preserve newlines so line count for grep, for start 27 | # of lines works. 28 | FILES=$(echo -n "$CHANGES" | wc -l) 29 | 30 | echo "All files changed: $FILES" 31 | 32 | ADD=$(echo -n "$CHANGES" | grep -c '^A') 33 | if [[ "$ADD" -ne 0 ]]; then 34 | echo "Added: $ADD" 35 | fi 36 | 37 | MOD=$(echo -n "$CHANGES" | grep -c '^.M') 38 | if [[ "$MOD" -ne 0 ]]; then 39 | echo "Modified: $MOD" 40 | fi 41 | 42 | DEL=$(echo -n "$CHANGES" | grep -c '^.D') 43 | if [[ "$DEL" -ne 0 ]]; then 44 | echo "Deleted: $DEL" 45 | fi 46 | 47 | REN=$(echo -n "$CHANGES" | grep -c '^R') 48 | if [[ "$REN" -ne 0 ]]; then 49 | echo "Renamed or moved: $REN" 50 | fi 51 | 52 | echo 53 | echo 'Original status:' 54 | echo 55 | echo "$CHANGES" 56 | -------------------------------------------------------------------------------- /shell/sample.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script is based on the prepare-commit-msg.sample file that comes by 4 | # default in any git repo when you run git init or git clone (you can also run 5 | # git init later on to restore hooks). 6 | # Note everything here is uncommented but you're only meant to run one of the 3 7 | # sections after setting up vars. 8 | 9 | # e.g. '.git/COMMIT_EDITMSG' 10 | COMMIT_MSG_FILE=$1 11 | # e.g. 'message' or 'template' 12 | COMMIT_SOURCE=$2 13 | # A hash, if relevant. 14 | SHA1=$3 15 | 16 | ### 17 | 18 | # Remove line. 19 | /usr/bin/perl -i.bak \ 20 | -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' \ 21 | "$COMMIT_MSG_FILE" 22 | 23 | ### 24 | 25 | # Show files changed. 26 | # (It will uncomment files changed in the message, which is maybe less 27 | # efficient than just running git status but this is what git comes with.) 28 | case "$COMMIT_SOURCE,$SHA1" in 29 | , | template,) 30 | /usr/bin/perl -i.bak -pe ' 31 | print "\n" . `git diff --cached --name-status -r` 32 | if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" 33 | ;; 34 | *) ;; 35 | esac 36 | -------------------------------------------------------------------------------- /shell/simple-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # A minimal Git commit hook. 3 | # 4 | # Usage: 5 | # ./simple-hook.sh MSG SRC SHA [-d] 6 | # 7 | # The last part is an optional non-standard flag, allowing this to be run 8 | # directly without writing out. 9 | # 10 | # This uses a call to `git status`, processes the text, writes out to a commit 11 | # message file. 12 | 13 | set -e 14 | 15 | # These are the 3 standard prepare-commit-msg args as per the hooks example and 16 | # Git hook docs. 17 | COMMIT_MSG_FILE=$1 18 | COMMIT_SOURCE=$2 19 | SHA1=$3 20 | 21 | CHANGES=$(git status -s -uno --porcelain) 22 | 23 | echo 'Input values' 24 | echo "COMMIT_MSG_FILE = $COMMIT_MSG_FILE" 25 | echo "COMMIT_SOURCE = $COMMIT_SOURCE" 26 | echo "SHA1 = $SHA1" 27 | 28 | MESSAGE="This is a dumb message inserted before your commit message to prove can works but there is no smart logic here 29 | $CHANGES" 30 | 31 | if [ "$4" = '-d' ]; then 32 | echo '\nDRY RUN' 33 | echo "$MESSAGE" 34 | else 35 | echo "$MESSAGE" >$COMMIT_MSG_FILE 36 | fi 37 | 38 | # There is a weird bug where the space before the first line goes missing, 39 | # but not in the dry run output. 40 | # 41 | # This is a message inserted before your commit message 42 | # M docs/installation.md 43 | # M shell/count-files.sh 44 | # M shell/simple-hook.sh 45 | 46 | # Even weird in the logs 47 | # This is a message inserted before your commit message 48 | # M docs/installation.md 49 | # M shell/count-files.sh 50 | # M shell/simple-hook.sh 51 | -------------------------------------------------------------------------------- /src/api/git.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for the VS Code Git Extension. 3 | * 4 | * For use in the extensions.ts script. 5 | * 6 | * Based on instructions: 7 | * https://github.com/microsoft/vscode/tree/master/extensions/git#api 8 | * 9 | * The file was copied below as is, except for the link pasted in the license section. 10 | */ 11 | 12 | /*--------------------------------------------------------------------------------------------- 13 | * Copyright (c) Microsoft Corporation. All rights reserved. 14 | * Licensed under the MIT License. See https://github.com/microsoft/vscode/blob/main/LICENSE.txt in the project root for license information. 15 | *--------------------------------------------------------------------------------------------*/ 16 | 17 | import { 18 | Uri, 19 | Event, 20 | Disposable, 21 | ProviderResult, 22 | Command, 23 | CancellationToken, 24 | } from "vscode"; 25 | export { ProviderResult } from "vscode"; 26 | 27 | export interface Git { 28 | readonly path: string; 29 | } 30 | 31 | export interface InputBox { 32 | value: string; 33 | } 34 | 35 | export const enum ForcePushMode { 36 | Force, 37 | ForceWithLease, 38 | } 39 | 40 | export const enum RefType { 41 | Head, 42 | RemoteHead, 43 | Tag, 44 | } 45 | 46 | export interface Ref { 47 | readonly type: RefType; 48 | readonly name?: string; 49 | readonly commit?: string; 50 | readonly remote?: string; 51 | } 52 | 53 | export interface UpstreamRef { 54 | readonly remote: string; 55 | readonly name: string; 56 | } 57 | 58 | export interface Branch extends Ref { 59 | readonly upstream?: UpstreamRef; 60 | readonly ahead?: number; 61 | readonly behind?: number; 62 | } 63 | 64 | export interface Commit { 65 | readonly hash: string; 66 | readonly message: string; 67 | readonly parents: string[]; 68 | readonly authorDate?: Date; 69 | readonly authorName?: string; 70 | readonly authorEmail?: string; 71 | readonly commitDate?: Date; 72 | } 73 | 74 | export interface Submodule { 75 | readonly name: string; 76 | readonly path: string; 77 | readonly url: string; 78 | } 79 | 80 | export interface Remote { 81 | readonly name: string; 82 | readonly fetchUrl?: string; 83 | readonly pushUrl?: string; 84 | readonly isReadOnly: boolean; 85 | } 86 | 87 | export const enum Status { 88 | INDEX_MODIFIED, 89 | INDEX_ADDED, 90 | INDEX_DELETED, 91 | INDEX_RENAMED, 92 | INDEX_COPIED, 93 | 94 | MODIFIED, 95 | DELETED, 96 | UNTRACKED, 97 | IGNORED, 98 | INTENT_TO_ADD, 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 | } 148 | 149 | export interface CommitOptions { 150 | all?: boolean | "tracked"; 151 | amend?: boolean; 152 | signoff?: boolean; 153 | signCommit?: boolean; 154 | empty?: boolean; 155 | noVerify?: boolean; 156 | requireUserConfig?: boolean; 157 | useEditor?: boolean; 158 | verbose?: boolean; 159 | /** 160 | * string - execute the specified command after the commit operation 161 | * undefined - execute the command specified in git.postCommitCommand 162 | * after the commit operation 163 | * null - do not execute any command after the commit operation 164 | */ 165 | postCommitCommand?: string | null; 166 | } 167 | 168 | export interface FetchOptions { 169 | remote?: string; 170 | ref?: string; 171 | all?: boolean; 172 | prune?: boolean; 173 | depth?: number; 174 | } 175 | 176 | export interface RefQuery { 177 | readonly contains?: string; 178 | readonly count?: number; 179 | readonly pattern?: string; 180 | readonly sort?: "alphabetically" | "committerdate"; 181 | } 182 | 183 | export interface BranchQuery extends RefQuery { 184 | readonly remote?: boolean; 185 | } 186 | 187 | export interface Repository { 188 | readonly rootUri: Uri; 189 | readonly inputBox: InputBox; 190 | readonly state: RepositoryState; 191 | readonly ui: RepositoryUIState; 192 | 193 | getConfigs(): Promise<{ key: string; value: string }[]>; 194 | getConfig(key: string): Promise; 195 | setConfig(key: string, value: string): Promise; 196 | getGlobalConfig(key: string): Promise; 197 | 198 | getObjectDetails( 199 | treeish: string, 200 | path: string, 201 | ): Promise<{ mode: string; object: string; size: number }>; 202 | detectObjectType( 203 | object: string, 204 | ): Promise<{ mimetype: string; encoding?: string }>; 205 | buffer(ref: string, path: string): Promise; 206 | show(ref: string, path: string): Promise; 207 | getCommit(ref: string): Promise; 208 | 209 | add(paths: string[]): Promise; 210 | revert(paths: string[]): Promise; 211 | clean(paths: string[]): Promise; 212 | 213 | apply(patch: string, reverse?: boolean): Promise; 214 | diff(cached?: boolean): Promise; 215 | diffWithHEAD(): Promise; 216 | diffWithHEAD(path: string): Promise; 217 | diffWith(ref: string): Promise; 218 | diffWith(ref: string, path: string): Promise; 219 | diffIndexWithHEAD(): Promise; 220 | diffIndexWithHEAD(path: string): Promise; 221 | diffIndexWith(ref: string): Promise; 222 | diffIndexWith(ref: string, path: string): Promise; 223 | diffBlobs(object1: string, object2: string): Promise; 224 | diffBetween(ref1: string, ref2: string): Promise; 225 | diffBetween(ref1: string, ref2: string, path: string): Promise; 226 | 227 | hashObject(data: string): Promise; 228 | 229 | createBranch(name: string, checkout: boolean, ref?: string): Promise; 230 | deleteBranch(name: string, force?: boolean): Promise; 231 | getBranch(name: string): Promise; 232 | getBranches( 233 | query: BranchQuery, 234 | cancellationToken?: CancellationToken, 235 | ): Promise; 236 | setBranchUpstream(name: string, upstream: string): Promise; 237 | 238 | getRefs( 239 | query: RefQuery, 240 | cancellationToken?: CancellationToken, 241 | ): Promise; 242 | 243 | getMergeBase(ref1: string, ref2: string): Promise; 244 | 245 | tag(name: string, upstream: string): Promise; 246 | deleteTag(name: string): Promise; 247 | 248 | status(): Promise; 249 | checkout(treeish: string): Promise; 250 | 251 | addRemote(name: string, url: string): Promise; 252 | removeRemote(name: string): Promise; 253 | renameRemote(name: string, newName: string): Promise; 254 | 255 | fetch(options?: FetchOptions): Promise; 256 | fetch(remote?: string, ref?: string, depth?: number): Promise; 257 | pull(unshallow?: boolean): Promise; 258 | push( 259 | remoteName?: string, 260 | branchName?: string, 261 | setUpstream?: boolean, 262 | force?: ForcePushMode, 263 | ): Promise; 264 | 265 | blame(path: string): Promise; 266 | log(options?: LogOptions): Promise; 267 | 268 | commit(message: string, opts?: CommitOptions): Promise; 269 | } 270 | 271 | export interface RemoteSource { 272 | readonly name: string; 273 | readonly description?: string; 274 | readonly url: string | string[]; 275 | } 276 | 277 | export interface RemoteSourceProvider { 278 | readonly name: string; 279 | readonly icon?: string; // codicon name 280 | readonly supportsQuery?: boolean; 281 | getRemoteSources(query?: string): ProviderResult; 282 | getBranches?(url: string): ProviderResult; 283 | publishRepository?(repository: Repository): Promise; 284 | } 285 | 286 | export interface RemoteSourcePublisher { 287 | readonly name: string; 288 | readonly icon?: string; // codicon name 289 | publishRepository(repository: Repository): Promise; 290 | } 291 | 292 | export interface Credentials { 293 | readonly username: string; 294 | readonly password: string; 295 | } 296 | 297 | export interface CredentialsProvider { 298 | getCredentials(host: Uri): ProviderResult; 299 | } 300 | 301 | export interface PostCommitCommandsProvider { 302 | getCommands(repository: Repository): Command[]; 303 | } 304 | 305 | export interface PushErrorHandler { 306 | handlePushError( 307 | repository: Repository, 308 | remote: Remote, 309 | refspec: string, 310 | error: Error & { gitErrorCode: GitErrorCodes }, 311 | ): Promise; 312 | } 313 | 314 | export type APIState = "uninitialized" | "initialized"; 315 | 316 | export interface PublishEvent { 317 | repository: Repository; 318 | branch?: string; 319 | } 320 | 321 | export interface API { 322 | readonly state: APIState; 323 | readonly onDidChangeState: Event; 324 | readonly onDidPublish: Event; 325 | readonly git: Git; 326 | readonly repositories: Repository[]; 327 | readonly onDidOpenRepository: Event; 328 | readonly onDidCloseRepository: Event; 329 | 330 | toGitUri(uri: Uri, ref: string): Uri; 331 | getRepository(uri: Uri): Repository | null; 332 | init(root: Uri): Promise; 333 | openRepository(root: Uri): Promise; 334 | 335 | registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; 336 | registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; 337 | registerCredentialsProvider(provider: CredentialsProvider): Disposable; 338 | registerPostCommitCommandsProvider( 339 | provider: PostCommitCommandsProvider, 340 | ): Disposable; 341 | registerPushErrorHandler(handler: PushErrorHandler): Disposable; 342 | } 343 | 344 | export interface GitExtension { 345 | readonly enabled: boolean; 346 | readonly onDidChangeEnablement: Event; 347 | 348 | /** 349 | * Returns a specific API version. 350 | * 351 | * Throws error if git extension is disabled. You can listen to the 352 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 353 | * to know when the extension becomes enabled/disabled. 354 | * 355 | * @param version Version number. 356 | * @returns API instance 357 | */ 358 | getAPI(version: 1): API; 359 | } 360 | 361 | export const enum GitErrorCodes { 362 | BadConfigFile = "BadConfigFile", 363 | AuthenticationFailed = "AuthenticationFailed", 364 | NoUserNameConfigured = "NoUserNameConfigured", 365 | NoUserEmailConfigured = "NoUserEmailConfigured", 366 | NoRemoteRepositorySpecified = "NoRemoteRepositorySpecified", 367 | NotAGitRepository = "NotAGitRepository", 368 | NotAtRepositoryRoot = "NotAtRepositoryRoot", 369 | Conflict = "Conflict", 370 | StashConflict = "StashConflict", 371 | UnmergedChanges = "UnmergedChanges", 372 | PushRejected = "PushRejected", 373 | RemoteConnectionError = "RemoteConnectionError", 374 | DirtyWorkTree = "DirtyWorkTree", 375 | CantOpenResource = "CantOpenResource", 376 | GitNotFound = "GitNotFound", 377 | CantCreatePipe = "CantCreatePipe", 378 | PermissionDenied = "PermissionDenied", 379 | CantAccessRemote = "CantAccessRemote", 380 | RepositoryNotFound = "RepositoryNotFound", 381 | RepositoryIsLocked = "RepositoryIsLocked", 382 | BranchNotFullyMerged = "BranchNotFullyMerged", 383 | NoRemoteReference = "NoRemoteReference", 384 | InvalidBranchName = "InvalidBranchName", 385 | BranchAlreadyExists = "BranchAlreadyExists", 386 | NoLocalChanges = "NoLocalChanges", 387 | NoStashFound = "NoStashFound", 388 | LocalChangesOverwritten = "LocalChangesOverwritten", 389 | NoUpstreamBranch = "NoUpstreamBranch", 390 | IsInSubmodule = "IsInSubmodule", 391 | WrongCase = "WrongCase", 392 | CantLockRef = "CantLockRef", 393 | CantRebaseMultipleBranches = "CantRebaseMultipleBranches", 394 | PatchDoesNotApply = "PatchDoesNotApply", 395 | NoPathFound = "NoPathFound", 396 | UnknownPath = "UnknownPath", 397 | EmptyCommitMessage = "EmptyCommitMessage", 398 | BranchFastForwardRejected = "BranchFastForwardRejected", 399 | BranchNotYetBorn = "BranchNotYetBorn", 400 | TagConflict = "TagConflict", 401 | } 402 | -------------------------------------------------------------------------------- /src/autofill.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Autofill module. 3 | */ 4 | import * as vscode from "vscode"; 5 | import { Repository } from "./api/git"; 6 | import { getChanges } from "./git/cli"; 7 | import { getCommitMsg, setCommitMsg } from "./gitExtension"; 8 | import { generateMsg } from "./prepareCommitMsg"; 9 | 10 | export const NO_LINES_MSG = `\ 11 | Unable to generate message as no changes files can be seen. 12 | Try saving your files or stage any new (untracked) files.\ 13 | `; 14 | 15 | /** 16 | * Generate and fill a commit message in the Git extenside sidebar. 17 | * 18 | * Steps: 19 | * 20 | * 1. Read Git command output and the message in the Git Extension commit message box. 21 | * 2. Generate a message. 22 | * 3. Push message value to the commit message box. 23 | * 24 | * This is based on `prefixCommit` from the `git-prefix` extension. 25 | */ 26 | export async function makeAndFillCommitMsg(repository: Repository) { 27 | const fileChanges = await getChanges(repository); 28 | 29 | console.debug("diff-index:", fileChanges); 30 | 31 | if (!fileChanges.length) { 32 | vscode.window.showErrorMessage(NO_LINES_MSG); 33 | return; 34 | } 35 | 36 | const oldMsg = getCommitMsg(repository); 37 | console.debug("Old message: ", oldMsg); 38 | 39 | const newMsg = generateMsg(fileChanges, oldMsg); 40 | console.debug("New message: ", newMsg); 41 | 42 | setCommitMsg(repository, newMsg); 43 | } 44 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CLI module. 3 | * 4 | * This is the entry-point for the Auto Commit Message tool when running as a 5 | * standalone shell script. 6 | * 7 | * This script does NOT interact with VS Code or Git, it only processes text. 8 | * 9 | * It simply receives text as an argument and prints output to `stdout` for use 10 | * in a hook flow. Or to `stderr`, in the case of a message not appropriate for 11 | * a commit message. 12 | * 13 | * See `shell/README.md` in docs. 14 | */ 15 | import { generateMsg } from "./prepareCommitMsg"; 16 | 17 | /** 18 | * Command-line entry-point. 19 | * 20 | * Expect multi-line text from `git diff-index` command as the first item in the 21 | * shell arguments. 22 | * 23 | * Returns a suitable generated commit message as text. 24 | */ 25 | function main(args: string[]) { 26 | const linesArg = args[0]; 27 | 28 | if (typeof linesArg === "undefined") { 29 | throw new Error("Exactly one argument is required - text from diff-index."); 30 | } 31 | 32 | const lines = linesArg.split("\n"); 33 | 34 | if (!lines.length) { 35 | throw new Error("No file changes found"); 36 | } 37 | 38 | const msg = generateMsg(lines); 39 | console.log(msg); 40 | } 41 | 42 | const args = process.argv.slice(2); 43 | main(args); 44 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extension module. 3 | * 4 | * This sets up the VS Code extension's command entry-point and applies logic in 5 | * the `prepareCommitMsg` module to a target branch. 6 | */ 7 | import * as vscode from "vscode"; 8 | import { API, Repository } from "./api/git"; 9 | import { makeAndFillCommitMsg } from "./autofill"; 10 | import { getGitExtension } from "./gitExtension"; 11 | 12 | function _validateFoundRepos(git: API) { 13 | let msg = ""; 14 | 15 | if (!git) { 16 | msg = "Unable to load Git Extension"; 17 | } else if (git.repositories.length === 0) { 18 | msg = 19 | "No repos found. Please open a repo or run `git init` then try again."; 20 | } 21 | 22 | if (msg) { 23 | vscode.window.showErrorMessage(msg); 24 | 25 | throw new Error(msg); 26 | } 27 | } 28 | 29 | /** 30 | * Get current repo when using multiple repos in the workspace. 31 | * 32 | * Or when using GitLens on a single repo, based on a reported issue. 33 | */ 34 | async function _handleRepos( 35 | git: API, 36 | sourceControl: vscode.SourceControl, 37 | ): Promise { 38 | const selectedRepo = git.repositories.find(repository => { 39 | const uri = sourceControl.rootUri; 40 | if (!uri) { 41 | console.warn("rootUri not set for current repo"); 42 | return false; 43 | } 44 | const repoPath = repository.rootUri.path; 45 | 46 | return repoPath === uri.path; 47 | }); 48 | 49 | return selectedRepo ?? false; 50 | } 51 | 52 | /** 53 | * Return a repo for single repo in the workspace. 54 | */ 55 | async function _handleRepo(git: API): Promise { 56 | return git.repositories[0]; 57 | } 58 | 59 | /** 60 | * Choose the relevant repo and apply autofill logic on files there. 61 | */ 62 | async function _chooseRepoForAutofill(sourceControl?: vscode.SourceControl) { 63 | const git = getGitExtension()!; 64 | _validateFoundRepos(git); 65 | 66 | vscode.commands.executeCommand("workbench.view.scm"); 67 | 68 | const selectedRepo = sourceControl 69 | ? await _handleRepos(git, sourceControl) 70 | : await _handleRepo(git); 71 | 72 | if (!selectedRepo) { 73 | const msg = "No repos found"; 74 | vscode.window.showErrorMessage(msg); 75 | throw new Error(msg); 76 | } 77 | 78 | await makeAndFillCommitMsg(selectedRepo); 79 | } 80 | 81 | /** 82 | * Set up the extension activation. 83 | * 84 | * The `autofill` command as configured in `package.json` will be triggered 85 | * and run the autofill logic for a repo. 86 | */ 87 | export function activate(context: vscode.ExtensionContext) { 88 | const disposable = vscode.commands.registerCommand( 89 | "commitMsg.autofill", 90 | _chooseRepoForAutofill, 91 | ); 92 | 93 | context.subscriptions.push(disposable); 94 | } 95 | 96 | // prettier-ignore 97 | // eslint-disable-next-line @typescript-eslint/no-empty-function 98 | export function deactivate() { } 99 | -------------------------------------------------------------------------------- /src/generate/action.d.ts: -------------------------------------------------------------------------------- 1 | import { ACTION } from "../lib/constants"; 2 | 3 | export type ActionKeys = keyof typeof ACTION; 4 | -------------------------------------------------------------------------------- /src/generate/action.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Action module. 3 | * 4 | * Phrase commit changes in words. 5 | * 6 | * This follows the git style of using `x` and `y`. See this project's docs for 7 | * more info. Note that `first` and `second` are not appropriate names as `git 8 | * status` and `git diff-index` behave differently. 9 | */ 10 | import { ACTION, ROOT } from "../lib/constants"; 11 | import { moveOrRenameFromPaths, quoteForSpaces, splitPath } from "../lib/paths"; 12 | import { ActionKeys } from "./action.d"; 13 | 14 | /** 15 | * Lookup the action (e.g. 'modified') for a given key (e.g. 'M'). 16 | */ 17 | export function lookupDiffIndexAction(x: string): ACTION { 18 | const action = ACTION[x as ActionKeys]; 19 | 20 | if (typeof action === "undefined") { 21 | throw new Error(`Unknown ACTION key: ${x}`); 22 | } 23 | 24 | return action; 25 | } 26 | 27 | /** 28 | * Return full message for moving and/or renaming a file. 29 | */ 30 | export function moveOrRenameMsg(oldPath: string, newPath: string): string { 31 | const oldP = splitPath(oldPath), 32 | newP = splitPath(newPath); 33 | 34 | const moveDesc = moveOrRenameFromPaths(oldP, newP); 35 | 36 | let msg; 37 | 38 | const from = quoteForSpaces(oldP.name); 39 | 40 | if (moveDesc === "move") { 41 | const to = quoteForSpaces(newP.dirPath); 42 | msg = `move ${from} to ${to}`; 43 | } else if (moveDesc === "rename") { 44 | const to = quoteForSpaces(newP.name); 45 | msg = `rename ${from} to ${to}`; 46 | } else { 47 | const to = quoteForSpaces(newP.name); 48 | const target = 49 | newP.dirPath === ROOT ? `${to} at ${ROOT}` : quoteForSpaces(newPath); 50 | msg = `move and rename ${from} to ${target}`; 51 | } 52 | 53 | return msg; 54 | } 55 | -------------------------------------------------------------------------------- /src/generate/convCommit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Conventional Commit module. 3 | * 4 | * Process file paths and how they changed and generate a type like 'chore' or 5 | * 'docs'. 6 | */ 7 | import { ACTION, CONVENTIONAL_TYPE } from "../lib/constants"; 8 | import { splitPath } from "../lib/paths"; 9 | import { 10 | BUILD_EXTENSIONS, 11 | BUILD_NAMES, 12 | CI_DIRS, 13 | CI_NAMES, 14 | CONFIG_DIRS, 15 | CONFIG_EXTENSIONS, 16 | CONFIG_NAMES, 17 | DOC_NAMES, 18 | LICENSE_NAMES, 19 | PACKAGE_NAMES, 20 | } from "./convCommitConstants"; 21 | 22 | /** 23 | * Evaluate conventional commit prefix for a given file. 24 | * 25 | * This ignores the action such as create/delete file. 26 | * 27 | * For move or rename cases, the input path is assumed to be the `to` path 28 | * as that would more useful than knowing the `from` path. 29 | */ 30 | export class ConventionalCommit { 31 | atRoot: boolean; 32 | dirPath: string; 33 | name: string; 34 | extension: string; 35 | 36 | constructor(filePath: string) { 37 | // TODO It is worth keeping splitPath on its own for separation of concerns, 38 | // but could it work better as a class? And then conv commit can inherit 39 | // from it. 40 | // The properties are actually all the same her as there (duplication), only 41 | // the conv commit methods get added here as new. 42 | // 43 | // Maybe a class is overkill as it is just a container of data. 44 | // Maybe the {} can be stored an object here. Or maybe combine that and this 45 | // at the risk of doing too much. But still easy to test attributes vs 46 | // methods. 47 | const { atRoot, dirPath, name, extension } = splitPath(filePath); 48 | this.atRoot = atRoot; 49 | this.dirPath = dirPath; 50 | this.name = name; 51 | this.extension = extension; 52 | } 53 | 54 | /** 55 | * Check if doc-related. 56 | * 57 | * Return true for `.rst`, README files, and anything in the docs directory. 58 | * 59 | * TODO: For static sites, not all `.md` files are docs but that could be 60 | * configured with a global flag. Or recognize presence of Jekyll config file. 61 | */ 62 | isDocsRelated(): boolean { 63 | if (this.extension === ".rst") { 64 | return true; 65 | } 66 | const lowerName = this.name.toLowerCase(); 67 | 68 | return DOC_NAMES.includes(lowerName) || this.dirPath.startsWith("docs"); 69 | } 70 | 71 | isTestRelated(): boolean { 72 | const dir = `${this.dirPath}/`; 73 | 74 | return ( 75 | dir.includes("test/") || 76 | dir.includes("tests/") || 77 | dir.includes("spec/") || 78 | dir.includes("__mocks__/") || 79 | dir.startsWith("unit") || 80 | this.name.includes(".test.") || 81 | this.name.includes(".spec.") || 82 | this.name.startsWith("test_") || 83 | this.name === ".coveragerc" 84 | ); 85 | } 86 | 87 | isCIRelated(): boolean { 88 | // Assume flat structure and don't check for nesting in subdirs of 89 | // `CI_DIRS`. 90 | return CI_DIRS.includes(this.dirPath) || CI_NAMES.includes(this.name); 91 | } 92 | 93 | // Broadly match eslint configs with any extension e.g. `.json` or `.yml`. 94 | // See https://eslint.org/docs/user-guide/configuring 95 | // Same for prettier configs https://prettier.io/docs/en/configuration.html 96 | // And `tslint*` as JSON or YAML and `webpack*`. 97 | // See https://github.com/vscode-icons/vscode-icons/blob/master/src/iconsManifest/supportedExtensions.ts 98 | isConfigRelated(): boolean { 99 | return ( 100 | CONFIG_EXTENSIONS.includes(this.extension) || 101 | CONFIG_DIRS.includes(this.dirPath) || 102 | CONFIG_NAMES.includes(this.name) || 103 | this.name.includes(".eslintrc") || 104 | this.name.includes(".eslintignore") || 105 | this.name.includes(".eslintcache") || 106 | this.name.includes(".prettier") || 107 | this.name.includes("tslint") || 108 | this.name.includes("webpack") 109 | ); 110 | } 111 | 112 | isPackageRelated(): boolean { 113 | return PACKAGE_NAMES.includes(this.name); 114 | } 115 | 116 | isBuildRelated(): boolean { 117 | return ( 118 | BUILD_NAMES.includes(this.name) || 119 | BUILD_EXTENSIONS.includes(this.extension) 120 | ); 121 | } 122 | 123 | isLicenseRelated(): boolean { 124 | return LICENSE_NAMES.includes(this.name); 125 | } 126 | 127 | isChoreRelated(): boolean { 128 | return this.isLicenseRelated() || this.isConfigRelated(); 129 | } 130 | 131 | /** 132 | * Return Conventional Commit type. 133 | * 134 | * Order of checks is important here. 135 | * 136 | * Return the unknown/null value if no rule matches. 137 | */ 138 | getType() { 139 | if (this.isCIRelated()) { 140 | return CONVENTIONAL_TYPE.CI; 141 | } 142 | if (this.isPackageRelated()) { 143 | return CONVENTIONAL_TYPE.BUILD_DEPENDENCIES; 144 | } 145 | if (this.isBuildRelated()) { 146 | return CONVENTIONAL_TYPE.BUILD; 147 | } 148 | if (this.isChoreRelated()) { 149 | return CONVENTIONAL_TYPE.CHORE; 150 | } 151 | if (this.isDocsRelated()) { 152 | return CONVENTIONAL_TYPE.DOCS; 153 | } 154 | if (this.isTestRelated()) { 155 | return CONVENTIONAL_TYPE.TEST; 156 | } 157 | 158 | return CONVENTIONAL_TYPE.UNKNOWN; 159 | } 160 | } 161 | 162 | /** 163 | * Get the Conventional Commit type. 164 | * 165 | * Relies on both the action performed and the path. 166 | * 167 | * Don't distinguish `ACTION.M` vs `ACTION.A`, as it could be a fix or feature. 168 | * So just use unknown/null value. Though it could be set as always feature or 169 | * docs as a general rule or config option on the project level or extension 170 | * level. 171 | */ 172 | export function getConventionType( 173 | action: ACTION, 174 | filePath: string, 175 | ): CONVENTIONAL_TYPE { 176 | if (action === ACTION.R || action === ACTION.D) { 177 | return CONVENTIONAL_TYPE.CHORE; 178 | } 179 | 180 | const convCommit = new ConventionalCommit(filePath); 181 | const commitType = convCommit.getType(); 182 | 183 | if (action === ACTION.A) { 184 | return commitType || CONVENTIONAL_TYPE.FEAT; 185 | } 186 | 187 | return commitType; 188 | } 189 | -------------------------------------------------------------------------------- /src/generate/convCommitConstants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Conventional Commit constants module. 3 | */ 4 | 5 | // Package-related can be for 'build'. 6 | export const PACKAGE_DIRS = [ 7 | // Rust 8 | ".cargo", 9 | ]; 10 | 11 | export const PACKAGE_NAMES = [ 12 | // Python 13 | "requirements.txt", 14 | "requirements-dev.txt", 15 | "dev-requirements.txt", 16 | "requirements-test.txt", 17 | "test-requirements.txt", 18 | 19 | "Pipfile", 20 | "Pipefile.lock", 21 | 22 | "poetry.toml", 23 | "poetry.lock", 24 | 25 | // Make Python package installable and manage external packages. 26 | "pyproject.toml", 27 | "setup.py", 28 | "setup.cfg", 29 | 30 | // Ruby 31 | "Gemfile", 32 | "Gemfile.lock", 33 | 34 | // NPM (Exclude package.json since it can be used for metadata and not 35 | // package always changes.) 36 | "package-lock.json", 37 | "shrinkwrap.json", 38 | "yarn.lock", 39 | 40 | // PHP 41 | "composer.json", 42 | "composer.lock", 43 | 44 | // Go 45 | "go.mod", 46 | "go.sum", 47 | 48 | // Rust 49 | "Cargo.toml", 50 | "Cargo.lock", 51 | 52 | // Deno 53 | "deps.ts", 54 | "test_deps.ts", 55 | "dev_deps.ts", 56 | "import_map.json", 57 | ]; 58 | 59 | // Build system (scripts, configurations or tools) and package dependencies. 60 | export const BUILD_NAMES = [ 61 | // Docker 62 | ".dockerignore", 63 | "Dockerfile", 64 | "docker-compose.yml", 65 | 66 | // Make 67 | "GNUmakefile", 68 | "makefile", 69 | "Makefile", 70 | 71 | // Ruby Rake - based on rake CLI message. 72 | "rakefile", 73 | "Rakefile", 74 | "rakefile.rb", 75 | "Rakefile.rb", 76 | 77 | "package.json", // Not necessarily package-related but always build-related. 78 | 79 | // Java 80 | "gradlew", 81 | "grailsw", 82 | "micronaut-cli.yml", 83 | ]; 84 | export const BUILD_EXTENSIONS = [ 85 | // Ruby installation 86 | ".gemspec", 87 | 88 | // Windows 89 | ".bat", 90 | 91 | // Java 92 | ".gradle", 93 | ]; 94 | 95 | // This may be too broad or clash with other areas such as CI or package, unless 96 | // used close to LAST in the entire flow. 97 | // Note also that prettier and ESLint configs with various extensions are 98 | // handled in isConfigRelated so don't have to be listed explictly. Though those 99 | // strings should be moved out of the function. 100 | export const CONFIG_EXTENSIONS = [ 101 | ".yml", 102 | ".yaml", 103 | ".json", 104 | ".toml", 105 | ".ini", 106 | ".cfg", 107 | ".config.js", // e.g. for rollup, jest and commitlint 108 | ]; 109 | export const CONFIG_DIRS = [".vscode"]; 110 | export const CONFIG_NAMES = [ 111 | // Git 112 | ".gitignore", 113 | 114 | // EditorConfig 115 | ".editorconfig", 116 | 117 | // Python 118 | ".pylintrc", 119 | ".isort.cfg", 120 | ".flake8", 121 | "pytest.ini", 122 | ".coveragerc", 123 | ".pylintrc", 124 | 125 | // Node 126 | ".npmignore", 127 | ".npmrc", 128 | ".babelrc", 129 | ".eslintrc", 130 | ".browserslistrc", 131 | "browserslist", 132 | "rollup.config.js", 133 | "webpack.config.js", 134 | "npm-shrinkwrap.json", 135 | "tsconfig.json", 136 | "tslint.json", 137 | 138 | // Dotenv 139 | ".env", 140 | ".env.dev", 141 | ".env.prod", 142 | ".env.tempate", 143 | ]; 144 | 145 | export const CI_DIRS = [".circleci", ".github/workflows"]; 146 | export const CI_NAMES = [ 147 | "netlify.toml", 148 | "travis.yml", 149 | "tox.ini", 150 | 151 | ".vscodeignore", 152 | 153 | "codecov.yml", 154 | ".codecov.yml", 155 | ".codeclimate.yml", 156 | 157 | // Zeit / Vercel 158 | "now.json", 159 | ".nowignore", 160 | "vercel.json", 161 | ".vercelignore", 162 | 163 | "Procfile", 164 | ]; 165 | 166 | // This can be useful for multi-file changes e.g. "Create 5 scripts" 167 | // It might be easier to leave out this list and assume everything is a script 168 | // unless it is a doc, markdown file or config. 169 | export const SCRIPT_EXTENSIONS = [ 170 | ".html", 171 | 172 | ".css", 173 | ".less", 174 | ".scss", 175 | 176 | ".js", 177 | ".jsx", 178 | ".ts", 179 | ".tsx", 180 | ".mjs", 181 | 182 | ".py", 183 | 184 | ".rb", 185 | 186 | ".java", 187 | ".jar", 188 | 189 | ".c", 190 | ".h", 191 | 192 | ".rs", 193 | 194 | ".go", 195 | 196 | ".php", 197 | ]; 198 | // For "Update 5 shell scripts" 199 | export const SHELL_SCRIPT_EXTENSION = ".sh"; 200 | 201 | export const LICENSE_NAMES = [ 202 | "LICENSE", 203 | "LICENSE.txt", 204 | "License.txt", 205 | "LICENSE-source", 206 | ]; 207 | 208 | // TODO: Use. 209 | export const DOCS_DIRS = [ 210 | "docs", 211 | ".github/ISSUE_TEMPLATE", 212 | ".github/PULL_REQUEST_TEMPLATE", 213 | ]; 214 | 215 | // Anything in `/docs` will be covered already so this is for the root and any 216 | // subdirectories. Don't worry about .rst as those are already cover as always 217 | // docs. While `.md` could be content for a static site or docs site. 218 | export const DOC_NAMES = [ 219 | "README", 220 | "README.md", 221 | "README.txt", 222 | 223 | "installation.md", 224 | "usage.md", 225 | "development.md", 226 | "deploy.md", 227 | 228 | // GitHub docs. 229 | "SECURITY.md", 230 | "CONTRIBUTING.md", 231 | "CHANGELOG.md", 232 | "RELEASES.md", 233 | "FUNDING.md", 234 | "PULL_REQUEST_TEMPLATE.md", 235 | "ISSUE_TEMPLATE.md", 236 | "CODE_OF_CONDUCT.md", 237 | 238 | "MAINTAINERS.txt", 239 | "CODEOWNERS", 240 | 241 | // Images. 242 | "sample.png", 243 | "sample-1.png", 244 | "sample-2.png", 245 | "screenshot.png", 246 | "preview.png", 247 | ].map(name => name.toLowerCase()); 248 | -------------------------------------------------------------------------------- /src/generate/count.d.ts: -------------------------------------------------------------------------------- 1 | // Allowed keys are strictly meant to be from ACTION constant, plus also 2 | // 'rename' (Git considers rename and move the same thing but it is useful to 3 | // differeniate here). Keep it flexible - don't bother to validate the key here 4 | // as this is used internally and from the user nd tests can handle it. 5 | export type FileChangesByAction = { [key: string]: { fileCount: number } }; 6 | -------------------------------------------------------------------------------- /src/generate/count.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Count module. 3 | * 4 | * Rather than dealing with individual file names, this module deals with 5 | * grouping files by actions and showing a count for each. 6 | * 7 | * e.g. 'create 5 files' (in different directories). 8 | * e.g. 'update 3 files in foo' (highest common directory). 9 | * e.g. 'update 16 files and delete 2 files' 10 | */ 11 | import { FileChange } from "../git/parseOutput.d"; 12 | import { moveOrRenameFromPaths, splitPath, _join } from "../lib/paths"; 13 | import { MoveOrRename } from "../lib/paths.d"; 14 | import { lookupDiffIndexAction } from "./action"; 15 | import { FileChangesByAction } from "./count.d"; 16 | 17 | const RenameKey = "R"; 18 | 19 | /** 20 | * Determine if a file change is for move, rename, or both. 21 | */ 22 | export function _moveOrRenameFromChange(item: FileChange): MoveOrRename { 23 | const oldP = splitPath(item.from); 24 | const newP = splitPath(item.to); 25 | 26 | return moveOrRenameFromPaths(oldP, newP); 27 | } 28 | 29 | /** 30 | * Group changes by action and add counts within each. 31 | * 32 | * TODO: ? Converts changes e.g. `x` from `'M'` to `'modified'`. 33 | */ 34 | export function _countByAction(changes: FileChange[]) { 35 | const result: FileChangesByAction = {}; 36 | 37 | for (const item of changes) { 38 | const action = 39 | item.x === RenameKey 40 | ? _moveOrRenameFromChange(item) 41 | : lookupDiffIndexAction(item.x); 42 | 43 | result[action] = result[action] || { fileCount: 0 }; 44 | result[action].fileCount++; 45 | } 46 | 47 | return result; 48 | } 49 | 50 | export function _pluralS(count: number) { 51 | return count === 1 ? "" : "s"; 52 | } 53 | 54 | /** 55 | * Return a human-readable message a single action and count value. 56 | * 57 | * Anything works for action - it is not enforced to be one of ACTION or the 58 | * MoveOrRename type. 59 | */ 60 | export function _formatOne(action: string, count: number) { 61 | return `${action} ${count} file${_pluralS(count)}`; 62 | } 63 | 64 | /** 65 | * Return full human-readable message using all actions and counts. 66 | */ 67 | export function _formatAll(actionCounts: FileChangesByAction) { 68 | const msgs = Object.entries(actionCounts).map(pair => { 69 | const [action, values] = pair; 70 | return _formatOne(action, values.fileCount); 71 | }); 72 | 73 | return _join(msgs); 74 | } 75 | 76 | /** 77 | * Return description of actions and counts for one or more file changes. 78 | */ 79 | export function countFilesDesc(changes: FileChange[]): string { 80 | const actionCounts = _countByAction(changes); 81 | 82 | return _formatAll(actionCounts); 83 | } 84 | -------------------------------------------------------------------------------- /src/generate/message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Message module. 3 | * 4 | * Create a commit message using text output that was generated by a `git` subcommand. 5 | */ 6 | import { parseDiffIndex } from "../git/parseOutput"; 7 | import { FileChange } from "../git/parseOutput.d"; 8 | import { ACTION } from "../lib/constants"; 9 | import { friendlyFile, humanList } from "../lib/paths"; 10 | import { equal } from "../lib/utils"; 11 | import { lookupDiffIndexAction, moveOrRenameMsg } from "./action"; 12 | import { ActionKeys } from "./action.d"; 13 | import { countFilesDesc } from "./count"; 14 | 15 | /** 16 | * Prepare a commit message based on a single changed file. 17 | * 18 | * A rename can be handled too - it just requires both the paths to be staged so 19 | * that git collapses D and A to a single R action. 20 | * 21 | * Using the variable name as 'from' is not really descriptive here but the 22 | * logic works. It's also possible to reverse 'from' and 'to' in `git status` 23 | * and `git diff-index` output or handle just the parseDiffIndex function to 24 | * make sure 'to' is always set and 'from' is null if it is not a move. 25 | * 26 | * Expects a single line string that came from a `git` subcommand and returns a 27 | * value like 'Update foo.txt'. 28 | */ 29 | export function oneChange(line: string) { 30 | const { x: actionChar, from, to } = parseDiffIndex(line); 31 | 32 | const action = lookupDiffIndexAction(actionChar); 33 | if (action === ACTION.R) { 34 | return moveOrRenameMsg(from, to); 35 | } 36 | 37 | const outputPath = friendlyFile(from); 38 | 39 | return `${action} ${outputPath}`; 40 | } 41 | 42 | /** 43 | * Describe an action and paths for a set of changed files. 44 | * 45 | * @param lines Expect one or more lines that came from a Git subcommand. 46 | * 47 | * @return A human-readable sentence decribing file changes. e.g. 'update 48 | * foo.txt and bar.txt'. 49 | */ 50 | export function namedFilesDesc(changes: FileChange[]) { 51 | const actions = changes.map(item => item.x as ActionKeys); 52 | const action = equal(actions) 53 | ? lookupDiffIndexAction(actions[0]) 54 | : ACTION.UNKNOWN; 55 | 56 | const pathsChanged = changes.map(item => item.from); 57 | const fileList = humanList(pathsChanged); 58 | 59 | if (action === ACTION.UNKNOWN) { 60 | return countFilesDesc(changes); 61 | } 62 | 63 | return `${action} ${fileList}`; 64 | } 65 | -------------------------------------------------------------------------------- /src/generate/parseExisting.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Commit message pieces. 3 | * 4 | * See docs in this project: 5 | * docs/manual/conventional-commits.md 6 | * 7 | * The `typePrefix` here is kept as a string type for simplicity and not having 8 | * to convert to `CONVENTIONAL_TYPE` too early in process where there isn't a 9 | * benefit. Also, when this is used to parse an existing message, there is no 10 | * guarantee that the user's type prefix is one of the expected types. 11 | * 12 | * See also the `ConvCommitMsg` type. 13 | */ 14 | export type MsgPieces = { 15 | customPrefix: string; 16 | typePrefix: string; 17 | description: string; 18 | }; 19 | -------------------------------------------------------------------------------- /src/generate/parseExisting.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse an existing commit message. 3 | * 4 | * This allows the pieces to be reused in the new message. 5 | */ 6 | import { MsgPieces } from "./parseExisting.d"; 7 | 8 | /* 9 | * Split commit message into prefixes (custom and type) and description. 10 | * 11 | * Require a colon to exist to detect type prefix. i.e. 'ci' will be considered 12 | * a description, but 'ci:' will be considered a prefix. This keeps the check 13 | * simpler as we don't have to match against every type and we don't have to 14 | * check if we are part of a word e.g. 'circus'. 15 | * 16 | * Warning - trimming prefixes here causes issues elsewhere and I don't know 17 | * why. 18 | */ 19 | export function _splitPrefixesAndDesc(value: string) { 20 | let prefixes = ""; 21 | let description = ""; 22 | 23 | if (value.includes(":")) { 24 | [prefixes, description] = value.split(":", 2); 25 | } else { 26 | description = value; 27 | } 28 | description = description.trim(); 29 | 30 | return { prefixes, description }; 31 | } 32 | 33 | /** 34 | * Split a prefix value into more granular prefixes. 35 | * 36 | * Expect the input value to be the part before a colon in a commit message. 37 | * 38 | * Formatted as: 39 | * - The Conventional Commit type prefix is the last word. This is just kept 40 | * as a string and here and not validated or with type enforce. 41 | * - The custom prefix will be one or more words before the type prefix 42 | * (multiple words would not be be typical though). This might be a Jira 43 | * identifier or project name. 44 | */ 45 | export function _splitPrefixes(value: string) { 46 | let customPrefix: string; 47 | let typePrefix: string; 48 | 49 | if (value && value.includes(" ")) { 50 | const items = value.split(" "); 51 | customPrefix = items.slice(0, -1).join(" "); 52 | typePrefix = items.slice(-1)[0]; 53 | } else { 54 | customPrefix = ""; 55 | typePrefix = value; 56 | } 57 | 58 | return { customPrefix, typePrefix }; 59 | } 60 | 61 | /** 62 | * Separate a commit message into prefixes and a description. 63 | * TODO: return type 64 | */ 65 | export function splitMsg(msg: string): MsgPieces { 66 | const { prefixes, description } = _splitPrefixesAndDesc(msg); 67 | const { customPrefix, typePrefix } = _splitPrefixes(prefixes); 68 | 69 | return { customPrefix, typePrefix, description: description.trim() }; 70 | } 71 | -------------------------------------------------------------------------------- /src/git/cli.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git CLI module. 3 | * 4 | * Run Git CLI commands within the extension and capture the text output. 5 | */ 6 | import { exec as _exec } from "child_process"; 7 | import * as util from "util"; 8 | import { Repository } from "../api/git"; 9 | 10 | const exec = util.promisify(_exec); 11 | 12 | // Ensure Git will show special characters literally without quoting the string 13 | // and escaping characters. 14 | const QUOTE_PATH = '-c "core.quotePath=false"'; 15 | 16 | const DIFF_INDEX_CMD = "diff-index"; 17 | const DIFF_INDEX_OPTIONS = [ 18 | "--name-status", 19 | "--find-renames", 20 | "--find-copies", 21 | "--no-color", 22 | ]; 23 | 24 | /** 25 | * Run a `git` subcommand and return the result, with stdout and stderr available. 26 | */ 27 | function _execute(cwd: string, subcommand: string, options: string[] = []) { 28 | const command = `git ${QUOTE_PATH} ${subcommand} ${options.join(" ")}`; 29 | 30 | console.debug(`Running command: ${command}, cwd: ${cwd}`); 31 | 32 | return exec(command, { cwd }); 33 | } 34 | 35 | /** 36 | * Run `git diff-index` with given flags and return the output. 37 | * 38 | * This will return both staged and unstaged changes. Pass '--cached' in 39 | * `options` param to use staged changes only. Always excludes untracked files. 40 | * 41 | * Empty lines will be dropped, because of no changes or just the way the 42 | * command-line data comes in or got split. 43 | * 44 | * The output already seems to never have color info, from testing, but the 45 | * no-color flagged is added still to be safe. 46 | */ 47 | async function _diffIndex( 48 | repository: Repository, 49 | options: string[] = [], 50 | ): Promise> { 51 | const cwd = repository.rootUri.fsPath; 52 | const fullOptions = [...DIFF_INDEX_OPTIONS, ...options, "HEAD"]; 53 | 54 | const { stdout, stderr } = await _execute(cwd, DIFF_INDEX_CMD, fullOptions); 55 | 56 | if (stderr) { 57 | console.debug(`stderr for 'git ${DIFF_INDEX_CMD}' command:`, stderr); 58 | } 59 | 60 | const lines = stdout.split("\n"); 61 | 62 | return lines.filter(line => line !== ""); 63 | } 64 | 65 | /** 66 | * List files changed and how they changed. 67 | * 68 | * Look for diff description of staged files, otherwise fall back to all files. 69 | * Always excludes untracked files - make sure to stage a file so it becomes 70 | * tracked, especially in the case of a rename. 71 | * 72 | * Returns an array of strings. 73 | */ 74 | export async function getChanges(repository: Repository) { 75 | const stagedChanges = await _diffIndex(repository, ["--cached"]); 76 | 77 | if (stagedChanges.length) { 78 | console.debug("Found staged changes"); 79 | 80 | return stagedChanges; 81 | } 82 | 83 | console.debug( 84 | "Staging area is empty. Using unstaged files (tracked files only still).", 85 | ); 86 | 87 | const allChanges = await _diffIndex(repository); 88 | 89 | if (!allChanges.length) { 90 | console.debug("No changes found"); 91 | } 92 | return allChanges; 93 | } 94 | -------------------------------------------------------------------------------- /src/git/parseOutput.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Git output type defs. 3 | */ 4 | 5 | /** 6 | * Describe changes to a file at a low-level as key-value pairs. 7 | * 8 | * The variable names come from git's naming for git status and git diff-index. 9 | * The `x` and `y` parts are actions like 'M' and they correspond to `from` and 10 | * `to` respectively. When updating a single file, only `from` is filled. When 11 | * moving or renaming, then `from` is the old path and `to` is the new path. 12 | * 13 | * There can also be percentage value for renaming, such 'R100' which is 100% 14 | * similar. But we discard any percentage value for the purposes of this project 15 | * when parsing a line. 16 | * 17 | * TODO: Use `ACTION` (fewest compile errors otherwise `keyof typeof ACTION`) 18 | * for `x` and `y` to enforce 'M' etc. See action.d.ts module. And consider 19 | * making a second type where the `x` and `y` are ACTION types as `modified` 20 | * etc. To save using `lookupDiffIndexAction` call in multiple places. 21 | */ 22 | export type FileChange = { 23 | x: string; 24 | y: string; 25 | to: string; 26 | from: string; 27 | }; 28 | -------------------------------------------------------------------------------- /src/git/parseOutput.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Git output module. 3 | * 4 | * Parse text output created by Git subcommands. 5 | */ 6 | import { FileChange } from "./parseOutput.d"; 7 | 8 | // This is NOT worth moving to constants, because this space is a value, while 9 | // the `DESCRIPTION` enum has it as a key. 10 | const UNMODIFIED = " "; 11 | 12 | const GIT_STATUS_SPLIT = " -> "; 13 | 14 | /** 15 | * Parse Git status output. 16 | * 17 | * Parse a line which was produced by the `git status --short` command. 18 | */ 19 | export function parseStatus(line: string): FileChange { 20 | if (line.length <= 4) { 21 | throw new Error( 22 | `Input string must be at least 4 characters. Got: '${line}'`, 23 | ); 24 | } 25 | const x = line[0]; 26 | const y = line[1]; 27 | 28 | const paths = line.substring(3); 29 | const [from, to] = paths.includes(GIT_STATUS_SPLIT) 30 | ? paths.split(GIT_STATUS_SPLIT) 31 | : [paths, ""]; 32 | 33 | return { 34 | x, 35 | y, 36 | from, 37 | to, 38 | }; 39 | } 40 | 41 | /** 42 | * Parse Git diff index subcommand output. 43 | * 44 | * Expect a line produced by the `git diff-index` subcommand and parse it into 45 | * an object describing the file changes. 46 | * 47 | * We keep `x` as a single letter here, even though the input might be include a 48 | * percentage that we ignore, as in 'R100 ...'. 49 | * 50 | * Unlike for `git status`, the `y` value will be missing here, so we set it to 51 | * Unmodified (a space). 52 | * 53 | * The `to` field will not always be set, so a null string is fine (and better 54 | * than `undefined`). 55 | */ 56 | export function parseDiffIndex(line: string): FileChange { 57 | if (line.length <= 4) { 58 | const errorMsg = `Invalid input. Input string must be at least 4 characters. Got: '${line}'`; 59 | console.error(errorMsg); 60 | throw new Error(errorMsg); 61 | } 62 | const x = line[0]; 63 | const y = UNMODIFIED; 64 | 65 | const [_, from, to] = line.split("\t"); 66 | if (!from) { 67 | // Unlikely in real life, but this helps in development. 68 | throw new Error(`Invalid input - could not find 'from' path: ${line}`); 69 | } 70 | 71 | return { 72 | x, 73 | y, 74 | from, 75 | to: to ?? "", 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/gitExtension.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git extension module. 3 | * 4 | * Perform tasks related to the built-in Git extension. 5 | * 6 | * This module takes care of the high-level flow of the extension, after a repo 7 | * is selected in the `extension.ts` module. 8 | * 9 | * Flow: 10 | * 1. read Git output 11 | * 2. process it with the logic in the generate module 12 | * 3. set the value in the commit message box. 13 | */ 14 | import * as vscode from "vscode"; 15 | import { GitExtension, Repository } from "./api/git"; 16 | 17 | /** 18 | * Fetch the commit message in the Git Extension. 19 | */ 20 | export function getCommitMsg(repository: Repository): string { 21 | return repository.inputBox.value; 22 | } 23 | 24 | /** 25 | * Set the commit message in the Git Extension pane's input. 26 | */ 27 | export function setCommitMsg(repository: Repository, msg: string) { 28 | repository.inputBox.value = msg; 29 | } 30 | 31 | /** 32 | * Return VS Code's built-in Git extension. 33 | */ 34 | export function getGitExtension() { 35 | const vscodeGit = vscode.extensions.getExtension("vscode.git"); 36 | const gitExtension = vscodeGit && vscodeGit.exports; 37 | 38 | return gitExtension && gitExtension.getAPI(1); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/commonPath.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get a common path of paths. 3 | * 4 | * The code comes from: 5 | * http://rosettacode.org/wiki/Find_common_directory_path#JavaScript 6 | * Since JS does not have a built-in function like Python does. 7 | * 8 | * This module is kept separate from `paths.ts` as all the code here is tightly 9 | * related. 10 | */ 11 | import { ROOT } from "../lib/constants"; 12 | 13 | /** 14 | * Split strings. 15 | * 16 | * For an array of strings, split each string into an array using the given 17 | * separator. 18 | */ 19 | export function _splitStrings(items: string[], sep = "/") { 20 | return items.map((item: string) => item.split(sep)); 21 | } 22 | 23 | /** 24 | * Get an element at a position. 25 | * 26 | * Given an index number, return a function that takes an array and returns the 27 | * element at the given index. 28 | */ 29 | function _elAt(index: number) { 30 | return (arr: { [x: string]: any }) => arr[index]; 31 | } 32 | 33 | /** 34 | * Transpose an array of arrays: 35 | * 36 | * Example: 37 | * [['a', 'b', 'c'], ['A', 'B', 'C'], [1, 2, 3]] -> 38 | * [['a', 'A', 1], ['b', 'B', 2], ['c', 'C', 3]] 39 | */ 40 | function _rotate(arr: string[][]): string[][] { 41 | return arr[0].map((_el: any, index: number) => arr.map(_elAt(index))); 42 | } 43 | 44 | /** 45 | * Check whether all the elements in an array are the same or not. 46 | */ 47 | function _allElementsEqual(arr: any[]) { 48 | const firstEl = arr[0]; 49 | 50 | return arr.every((el: any) => el === firstEl); 51 | } 52 | 53 | /** 54 | * Return common directory for an array of paths. 55 | * 56 | * This can be useful for one file going from source to destination. Or finding 57 | * the top-most directory that is common to a few files that all changed. 58 | */ 59 | export function commonPath(input: string[], sep = "/"): string { 60 | const s = _splitStrings(input, sep); 61 | 62 | const common = _rotate(s).filter(_allElementsEqual).map(_elAt(0)).join(sep); 63 | 64 | return common === "" ? ROOT : common; 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Map Git status short symbols to a long description. 3 | */ 4 | // Use file names up to a point and after than using file counts. 5 | export const AGGREGATE_MIN = 5; 6 | 7 | // Human-friendly description of path, for use in commit messages. 8 | export const ROOT = "repo root"; 9 | 10 | /** 11 | * Mapping of change descriptions. 12 | * 13 | * See documentation: 14 | * docs/development/advanced/changes.md 15 | */ 16 | export enum DESCRIPTION { 17 | " " = "unmodified", 18 | M = "modified", 19 | A = "added", 20 | D = "deleted", 21 | R = "renamed", 22 | C = "copied", 23 | U = "unmerged", 24 | "?" = "untracked", 25 | "!" = "ignored", 26 | } 27 | 28 | /** 29 | * Describe actions in commit message sentences. 30 | * 31 | * See documentation: 32 | * docs/development/advanced/changes.md 33 | */ 34 | export enum ACTION { 35 | M = "update", 36 | A = "create", 37 | D = "delete", 38 | R = "rename", 39 | C = "copy", 40 | UNKNOWN = "", 41 | } 42 | 43 | /** 44 | * Conventional Commit types. 45 | * 46 | * This applies also for "typePrefix" variables, when "conventional" is omitted 47 | * for brevity. 48 | * 49 | * The scope is mixed in here, because refactoring a few modules 50 | * to handle scope alone is too much effort for one use-case of "deps". 51 | * 52 | * See documentation: 53 | * docs/manual/conventional-commits.md 54 | */ 55 | export enum CONVENTIONAL_TYPE { 56 | BUILD = "build", 57 | BUILD_DEPENDENCIES = "build(deps)", 58 | CI = "ci", 59 | CHORE = "chore", 60 | DOCS = "docs", 61 | FEAT = "feat", 62 | FIX = "fix", 63 | PERF = "perf", 64 | REFACTOR = "refactor", 65 | REVERT = "revert", 66 | STYLE = "style", 67 | TEST = "test", 68 | UNKNOWN = "", 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/paths.d.ts: -------------------------------------------------------------------------------- 1 | export interface SplitPathResult { 2 | atRoot: boolean; 3 | dirPath: string; 4 | name: string; 5 | extension: string; 6 | } 7 | 8 | export type MoveOrRename = "move" | "rename" | "move and rename"; 9 | -------------------------------------------------------------------------------- /src/lib/paths.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Paths module. 3 | * 4 | * Helper functions dealing with file paths. 5 | */ 6 | import * as path from "path"; 7 | import { ROOT } from "../lib/constants"; 8 | import { MoveOrRename, SplitPathResult } from "./paths.d"; 9 | 10 | // The starts of filenames which might be repeated as files in a repo. Kept as 11 | // all lowercase here. 12 | const REPEAT_FILENAMES = ["readme", "index", "__init__.py"]; 13 | 14 | /** 15 | * Get metadata for a given path. 16 | * 17 | * This is done in one function call to save having to do separate calls or 18 | * having to the built-in string methods all over the project. 19 | * 20 | * Info is derived based on the input value as string, not whether the path to a 21 | * file that exists or not. 22 | * 23 | * Note that `path.extname` is already smart enough to detect only the last 24 | * extension if there are multiple dots as see extension as empty string if it 25 | * is '.filename'. Note that extension will have a dot. 26 | */ 27 | export function splitPath(filePath: string): SplitPathResult { 28 | const dir = path.dirname(filePath), 29 | isAtRepoRoot = dir === "."; 30 | 31 | return { 32 | atRoot: isAtRepoRoot, 33 | dirPath: isAtRepoRoot ? ROOT : dir, 34 | name: path.basename(filePath), 35 | extension: path.extname(filePath), 36 | }; 37 | } 38 | 39 | /** Format to add quotes if the values contains spaces. */ 40 | export function quoteForSpaces(value: string) { 41 | if (value.includes(" ") && value !== ROOT) { 42 | return `'${value}'`; 43 | } 44 | 45 | return value; 46 | } 47 | 48 | /** 49 | * Change file path to a more readable format. 50 | * 51 | * The idea is to show just the filename and take out the directory path, to 52 | * keep things short. The README- and index-related files can be confusing as 53 | * there might be be a few in a project, so those are kept as full paths. 54 | */ 55 | export function friendlyFile(filePath: string) { 56 | const { name } = splitPath(filePath); 57 | 58 | const nameLower = name.toLowerCase(); 59 | 60 | for (const p of REPEAT_FILENAMES) { 61 | if (nameLower.startsWith(p)) { 62 | return quoteForSpaces(filePath); 63 | } 64 | } 65 | return quoteForSpaces(name); 66 | } 67 | 68 | /** 69 | * Join a list of items using commas and an "and" word. 70 | * 71 | * These don't have to be file paths but usually are for this project. 72 | */ 73 | export function _join(items: string[]) { 74 | if (!items.length) { 75 | return ""; 76 | } 77 | 78 | if (items.length === 1) { 79 | return items[0]; 80 | } 81 | 82 | const firstItems = items.slice(0, items.length - 1); 83 | const lastItem = items.slice(-1); 84 | 85 | const start = firstItems.join(", "); 86 | 87 | return `${start} and ${lastItem}`; 88 | } 89 | 90 | /** 91 | * Express a list in plain English. 92 | * 93 | * Convert an array of paths to a human-readable sentence listing all the paths. 94 | * To keep things sane, filenames will be used without paths where possible. 95 | * 96 | * Leave order as in comes it - though sorting could be added if needed. Git 97 | * might already be doing some useful sorting. 98 | */ 99 | export function humanList(paths: string[]) { 100 | if (!paths.length) { 101 | throw new Error("Expected at least one path, got zero"); 102 | } 103 | paths = paths.map(path => friendlyFile(path)); 104 | 105 | if (paths.length === 1) { 106 | return paths[0]; 107 | } 108 | 109 | return _join(paths); 110 | } 111 | 112 | /** 113 | * Determine if a pair of paths represents a move, rename, or both. 114 | */ 115 | export function moveOrRenameFromPaths( 116 | oldP: SplitPathResult, 117 | newP: SplitPathResult, 118 | ) { 119 | let result: MoveOrRename; 120 | 121 | if (oldP.name === newP.name) { 122 | result = "move"; 123 | } else if (oldP.dirPath === newP.dirPath) { 124 | result = "rename"; 125 | } else { 126 | result = "move and rename"; 127 | } 128 | 129 | return result; 130 | } 131 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if all items in an array have the same value or not. 3 | */ 4 | export function equal(items: Array) { 5 | return items.every(i => i === items[0]); 6 | } 7 | -------------------------------------------------------------------------------- /src/prepareCommitMsg.d.ts: -------------------------------------------------------------------------------- 1 | import { CONVENTIONAL_TYPE } from "./lib/constants"; 2 | 3 | /** 4 | * Standard components of Conventional Commit Message but reduced. 5 | * 6 | * See documentation in this project: 7 | * docs/manual/conventional-commits.md 8 | * 9 | * For simplicity, scope is used inside the `typePrefix` rather than a separate 10 | * attribute - see the docstring on `CONVENTIONAL_TYPE`. 11 | */ 12 | export type ConvCommitMsg = { 13 | typePrefix: CONVENTIONAL_TYPE; 14 | description: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/prepareCommitMsg.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prepare commit message. 3 | * 4 | * This module ties together logic from independent modules in the `generate` 5 | * module. So it is best kept on the outside here. 6 | * 7 | * The "message" is the full commit message. The "file change description" is 8 | * the description portion, which describes how the files changed. 9 | * 10 | * This module doesn't interact with the git CLI or the extension. It just deals 11 | * with text. 12 | */ 13 | import { lookupDiffIndexAction } from "./generate/action"; 14 | import { getConventionType } from "./generate/convCommit"; 15 | import { countFilesDesc } from "./generate/count"; 16 | import { namedFilesDesc, oneChange } from "./generate/message"; 17 | import { splitMsg } from "./generate/parseExisting"; 18 | import { MsgPieces } from "./generate/parseExisting.d"; 19 | import { parseDiffIndex } from "./git/parseOutput"; 20 | import { AGGREGATE_MIN, CONVENTIONAL_TYPE } from "./lib/constants"; 21 | import { equal } from "./lib/utils"; 22 | import { ConvCommitMsg } from "./prepareCommitMsg.d"; 23 | 24 | /** 25 | * Join two strings together with a space. 26 | * 27 | * Use only one string if only one is set or if they are identical. 28 | * 29 | * Trimming on the outside is necessary here, in case only one item is set. 30 | */ 31 | export function _joinWithSpace(first: string, second: string) { 32 | first = first.trim(); 33 | second = second.trim(); 34 | 35 | if (first === second) { 36 | return first; 37 | } 38 | return `${first} ${second}`.trim(); 39 | } 40 | 41 | /** 42 | * Join two strings using a colon and space. 43 | */ 44 | export function _joinWithColon(first: string, second: string): string { 45 | return `${first}: ${second}`; 46 | } 47 | 48 | /** 49 | * Determine the Conventional Commit type prefix for a file change. 50 | * 51 | * @param line Description of a file change from Git output. e.g. "A baz.txt" 52 | */ 53 | export function _prefixFromChange(line: string) { 54 | const { x: actionChar, from: filePath } = parseDiffIndex(line); 55 | const action = lookupDiffIndexAction(actionChar); 56 | 57 | return getConventionType(action, filePath); 58 | } 59 | 60 | /** 61 | * Generate message for a single file change. 62 | * 63 | * @param line Description of a file change from Git output. e.g. "A baz.txt" 64 | */ 65 | export function _msgOne(line: string) { 66 | // TODO: Pass FileChanges to oneChange and _prefixFromChange instead of string. 67 | // Don't unpack as {x, y, from, to} 68 | // const fileChanges = parseDiffIndex(line) 69 | const typePrefix = _prefixFromChange(line), 70 | description = oneChange(line); 71 | 72 | return { typePrefix, description }; 73 | } 74 | 75 | /** 76 | * Determine a single type prefix from multiple values given. 77 | * 78 | * @param types An array of Convention Commit type prefixes. 79 | * 80 | * @returns A single prefix type. 81 | * - Unknown if zero items - not likely in real life but covered anyway. 82 | * - The first item if they are equal. 83 | * - Use unknown if they are different. 84 | * - If at least one item is build dependencies even if the others are 85 | * different, then use that. This covers the case where `package.json` may 86 | * have non-deps changes, but the `package-lock.json` is enough to want to 87 | * use the deps scope. 88 | */ 89 | export function _collapse(types: CONVENTIONAL_TYPE[]) { 90 | let result = CONVENTIONAL_TYPE.UNKNOWN; 91 | 92 | if (!types.length) { 93 | return result; 94 | } 95 | 96 | if (equal(types)) { 97 | result = types[0]; 98 | } else if (types.includes(CONVENTIONAL_TYPE.BUILD_DEPENDENCIES)) { 99 | result = CONVENTIONAL_TYPE.BUILD_DEPENDENCIES; 100 | } 101 | 102 | return result; 103 | } 104 | 105 | /** 106 | * Generate prefix and named description for multiple file changes. 107 | * 108 | * This finds a common Conventional Commit prefix if one is appropriate and 109 | * returns a message listing all the file names. 110 | * 111 | * @param lines An array of values describing file change from Git output. 112 | * e.g. ["A baz.txt"] 113 | */ 114 | export function _msgNamed(lines: string[]): ConvCommitMsg { 115 | const conventions = lines.map(_prefixFromChange); 116 | const typePrefix = _collapse(conventions); 117 | 118 | const changes = lines.map(parseDiffIndex); 119 | const description = namedFilesDesc(changes); 120 | 121 | return { typePrefix, description }; 122 | } 123 | 124 | /** 125 | * Generate prefix and count description for multiple file changes. 126 | * 127 | * @param lines An array of values describing file change from Git output. 128 | * e.g. ["A baz.txt"] 129 | */ 130 | export function _msgCount(lines: string[]): ConvCommitMsg { 131 | const prefix = CONVENTIONAL_TYPE.UNKNOWN; 132 | 133 | const changes = lines.map(parseDiffIndex); 134 | const description = countFilesDesc(changes); 135 | 136 | return { typePrefix: prefix, description }; 137 | } 138 | 139 | /** 140 | * Generate message from changes to one or more files. 141 | * 142 | * @param lines An array of values describing file change from Git output. 143 | * e.g. ["A baz.txt"] 144 | * 145 | * @returns Commit message containing a type prefix and a description of changed 146 | * paths. 147 | */ 148 | export function _msgFromChanges(lines: string[]) { 149 | let result: ConvCommitMsg; 150 | 151 | if (lines.length === 1) { 152 | const line = lines[0]; 153 | result = _msgOne(line); 154 | } else if (lines.length < AGGREGATE_MIN) { 155 | result = _msgNamed(lines); 156 | } else { 157 | result = _msgCount(lines); 158 | } 159 | 160 | return result; 161 | } 162 | 163 | /** 164 | * Output a readable conventional commit message. 165 | * 166 | * Use the Conventional Commit type as the prefix if it is known, otherwise 167 | * just use the description. 168 | */ 169 | export function _formatMsg(convCommitMsg: ConvCommitMsg) { 170 | if (convCommitMsg.typePrefix === CONVENTIONAL_TYPE.UNKNOWN) { 171 | return convCommitMsg.description; 172 | } 173 | return _joinWithColon(convCommitMsg.typePrefix, convCommitMsg.description); 174 | } 175 | 176 | /** 177 | * Generate a new commit message and format it as a string. 178 | * 179 | * @param lines An array of values describing file change from Git output. 180 | * e.g. ["A baz.txt"] 181 | */ 182 | export function _newMsg(lines: string[]) { 183 | const convCommitMsg = _msgFromChanges(lines); 184 | 185 | return _formatMsg(convCommitMsg); 186 | } 187 | 188 | /** 189 | * Combine old and generated values as a single commit message. 190 | * 191 | * @param autoMsgPieces Auto-generated new commit message pieces. 192 | * @param oldMsgPieces The original commit message pieces. 193 | */ 194 | export function _joinOldAndNew( 195 | autoMsgPieces: ConvCommitMsg, 196 | oldMsgPieces: MsgPieces, 197 | ): string { 198 | let typePrefix = ""; 199 | 200 | if (oldMsgPieces.typePrefix) { 201 | typePrefix = oldMsgPieces.typePrefix; 202 | } else if (autoMsgPieces.typePrefix !== CONVENTIONAL_TYPE.UNKNOWN) { 203 | typePrefix = autoMsgPieces.typePrefix; 204 | } 205 | 206 | const descResult = _joinWithSpace( 207 | autoMsgPieces.description, 208 | oldMsgPieces.description, 209 | ); 210 | 211 | if (!typePrefix) { 212 | return descResult; 213 | } 214 | 215 | const prefix = _joinWithSpace(oldMsgPieces.customPrefix, typePrefix); 216 | 217 | return _joinWithColon(prefix, descResult); 218 | } 219 | 220 | /** 221 | * Create a commit message using an existing message and generated pieces. 222 | * 223 | * The point is to always use the new description, but respect the old 224 | * description. 225 | * 226 | * An old type (possibly manually generated) must take preference over a 227 | * generated one. 228 | * 229 | * See the "common scenarios" part of `prepareCommitMsg.test.ts` test spec. 230 | * 231 | * @param autoType The Conventional Commit type to use, as auto-generated by the 232 | * extension, based on changed files. 233 | * @param autoDesc A description of file changes, also auto-generated. 234 | * @param oldMsg Value that exists in the commit message box at the time the 235 | * extension is run, whether typed manually or generated previously by the 236 | * extension. It could be a mix of custom prefix, type prefix, and 237 | * description. 238 | */ 239 | export function _combineOldAndNew( 240 | autoType: CONVENTIONAL_TYPE, 241 | autoDesc: string, 242 | oldMsg: string, 243 | ): string { 244 | if (!oldMsg) { 245 | const autoCommitMsg: ConvCommitMsg = { 246 | typePrefix: autoType, 247 | description: autoDesc, 248 | }; 249 | 250 | return _formatMsg(autoCommitMsg); 251 | } 252 | 253 | const oldMsgPieces = splitMsg(oldMsg); 254 | 255 | const autoMsgPieces: ConvCommitMsg = { 256 | typePrefix: autoType, 257 | description: autoDesc, 258 | }; 259 | 260 | return _joinOldAndNew(autoMsgPieces, oldMsgPieces); 261 | } 262 | 263 | /** 264 | * Generate commit message using existing message and new generated message. 265 | * 266 | * High-level function to process file changes and an old message, to generate a 267 | * replacement commit message. 268 | * 269 | * @param lines An array of values describing file change from Git output. 270 | * e.g. ["A baz.txt"] 271 | * @param oldMsg Existing commit message. 272 | */ 273 | export function _generateMsgWithOld(lines: string[], oldMsg: string) { 274 | if (oldMsg === "") { 275 | throw new Error( 276 | "`oldMsg` must be non-empty here, or use `generateNewMsg` instead.", 277 | ); 278 | } 279 | const { typePrefix, description } = _msgFromChanges(lines); 280 | 281 | return _combineOldAndNew(typePrefix, description, oldMsg); 282 | } 283 | 284 | /** 285 | * Generate commit message. 286 | * 287 | * A public wrapper function to allow an existing message to be set. 288 | * 289 | * @param lines An array of values describing file change from Git output. 290 | * e.g. ["A baz.txt"] 291 | * @param oldMsg Existing commit message.This could be the current commit 292 | * message value in the UI box (which might be a commit message template that 293 | * VS Code has filled in), or a commit message template read from a file in 294 | * the case of a hook flow without VS Code. 295 | */ 296 | export function generateMsg(lines: string[], oldMsg?: string): string { 297 | if (!oldMsg) { 298 | return _newMsg(lines); 299 | } 300 | 301 | return _generateMsgWithOld(lines, oldMsg); 302 | } 303 | -------------------------------------------------------------------------------- /src/test/generate/action.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Action test module to check action verbs used. 3 | */ 4 | import * as assert from "assert"; 5 | import { lookupDiffIndexAction, moveOrRenameMsg } from "../../generate/action"; 6 | 7 | describe("Desribe a file using a single path", function () { 8 | describe("#lookupDiffIndexAction", function () { 9 | it("can describe an updated file", function () { 10 | assert.strictEqual(lookupDiffIndexAction("M"), "update"); 11 | }); 12 | 13 | it("can describe a created file", function () { 14 | assert.strictEqual(lookupDiffIndexAction("A"), "create"); 15 | }); 16 | 17 | it("can describe a deleted file", function () { 18 | assert.strictEqual(lookupDiffIndexAction("D"), "delete"); 19 | }); 20 | 21 | it("can describe a renamed file", function () { 22 | assert.strictEqual(lookupDiffIndexAction("R"), "rename"); 23 | }); 24 | 25 | it("can describe a copied file", function () { 26 | assert.strictEqual(lookupDiffIndexAction("C"), "copy"); 27 | }); 28 | 29 | it("can throws an error for a bad key", function () { 30 | assert.throws(() => lookupDiffIndexAction("Z")); 31 | }); 32 | }); 33 | }); 34 | 35 | describe("Desribe a file using two paths", function () { 36 | describe("#moveOrRenameFile", function () { 37 | it("can describe a renamed file", function () { 38 | assert.strictEqual( 39 | moveOrRenameMsg("foo.txt", "bar.txt"), 40 | "rename foo.txt to bar.txt", 41 | ); 42 | 43 | assert.strictEqual( 44 | moveOrRenameMsg("buzz/foo.txt", "buzz/bar.txt"), 45 | "rename foo.txt to bar.txt", 46 | ); 47 | 48 | assert.strictEqual( 49 | moveOrRenameMsg("fizz buzz/foo.txt", "fizz buzz/bar.txt"), 50 | "rename foo.txt to bar.txt", 51 | ); 52 | }); 53 | 54 | it("can describe a moved file", function () { 55 | assert.strictEqual( 56 | moveOrRenameMsg("buzz/foo.txt", "fizz/foo.txt"), 57 | "move foo.txt to fizz", 58 | ); 59 | 60 | assert.strictEqual( 61 | moveOrRenameMsg("buzz/foo bar.txt", "fizz/foo bar.txt"), 62 | "move 'foo bar.txt' to fizz", 63 | ); 64 | 65 | assert.strictEqual( 66 | moveOrRenameMsg("buzz/foo.txt", "foo.txt"), 67 | "move foo.txt to repo root", 68 | ); 69 | 70 | assert.strictEqual( 71 | moveOrRenameMsg("buzz/foo bar.txt", "foo bar.txt"), 72 | "move 'foo bar.txt' to repo root", 73 | ); 74 | }); 75 | 76 | it("can describe a remamed and moved file", function () { 77 | assert.strictEqual( 78 | moveOrRenameMsg("foo.txt", "fizz/bar.txt"), 79 | "move and rename foo.txt to fizz/bar.txt", 80 | ); 81 | 82 | // This is a rare case, so don't bother trying to handle it smarter around 83 | // paths. 84 | assert.strictEqual( 85 | moveOrRenameMsg("fuzz/foo.txt", "fizz/bar.txt"), 86 | "move and rename foo.txt to fizz/bar.txt", 87 | ); 88 | 89 | assert.strictEqual( 90 | moveOrRenameMsg("fuzz/foo.txt", "fizz/bar bazz.txt"), 91 | "move and rename foo.txt to 'fizz/bar bazz.txt'", 92 | ); 93 | 94 | assert.strictEqual( 95 | moveOrRenameMsg("fizz/foo.txt", "bar.txt"), 96 | "move and rename foo.txt to bar.txt at repo root", 97 | ); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/test/generate/convCommit.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Conventional commit test module. 3 | * 4 | * Test the categorization of changed files as conventional commit types. 5 | */ 6 | import * as assert from "assert"; 7 | import { 8 | ConventionalCommit, 9 | getConventionType, 10 | } from "../../generate/convCommit"; 11 | import { ACTION, CONVENTIONAL_TYPE } from "../../lib/constants"; 12 | 13 | describe("Test #ConventionalCommit class for path-based conventional commit logic", function () { 14 | describe("#isDocsRelated", function () { 15 | it("determines that a README file is a doc", function () { 16 | assert.strictEqual( 17 | new ConventionalCommit("README.md").isDocsRelated(), 18 | true, 19 | ); 20 | assert.strictEqual( 21 | new ConventionalCommit("README.rst").isDocsRelated(), 22 | true, 23 | ); 24 | assert.strictEqual( 25 | new ConventionalCommit("Readme.txt").isDocsRelated(), 26 | true, 27 | ); 28 | assert.strictEqual( 29 | new ConventionalCommit("readme").isDocsRelated(), 30 | true, 31 | ); 32 | 33 | assert.strictEqual( 34 | new ConventionalCommit("FEEDME.md").isDocsRelated(), 35 | false, 36 | ); 37 | }); 38 | 39 | it("determines that a CONTRIBUTING file is a doc", function () { 40 | assert.strictEqual( 41 | new ConventionalCommit("CONTRIBUTING.md").isDocsRelated(), 42 | true, 43 | ); 44 | assert.strictEqual( 45 | new ConventionalCommit("contributing.md").isDocsRelated(), 46 | true, 47 | ); 48 | }); 49 | 50 | it("determines that a `.rst` file is a doc", function () { 51 | assert.strictEqual( 52 | new ConventionalCommit("README.rst").isDocsRelated(), 53 | true, 54 | ); 55 | assert.strictEqual( 56 | new ConventionalCommit("foo.rst").isDocsRelated(), 57 | true, 58 | ); 59 | }); 60 | 61 | it("determines a file in the docs directory is a doc", function () { 62 | assert.strictEqual( 63 | new ConventionalCommit("docs/fizz.md").isDocsRelated(), 64 | true, 65 | ); 66 | assert.strictEqual( 67 | new ConventionalCommit("docs/foo.img").isDocsRelated(), 68 | true, 69 | ); 70 | 71 | assert.strictEqual( 72 | new ConventionalCommit("docs/fizz/foo.img").isDocsRelated(), 73 | true, 74 | ); 75 | 76 | assert.strictEqual( 77 | new ConventionalCommit("fuzz/fizz.md").isDocsRelated(), 78 | false, 79 | ); 80 | }); 81 | }); 82 | 83 | describe("#isBuildRelated", function () { 84 | it("can recognize a build change for a build-related filename", function () { 85 | assert.strictEqual( 86 | new ConventionalCommit("Dockerfile").isBuildRelated(), 87 | true, 88 | ); 89 | assert.strictEqual( 90 | new ConventionalCommit("foo/Dockerfile").isBuildRelated(), 91 | true, 92 | ); 93 | 94 | assert.strictEqual( 95 | new ConventionalCommit("foo.txt").isBuildRelated(), 96 | false, 97 | ); 98 | assert.strictEqual( 99 | new ConventionalCommit("fizz/foo.txt").isBuildRelated(), 100 | false, 101 | ); 102 | }); 103 | }); 104 | 105 | describe("#isCIRelated", function () { 106 | it("can tell a CI change is in a CircleCI directory", function () { 107 | assert.strictEqual( 108 | new ConventionalCommit(".circleci/foo.txt").isCIRelated(), 109 | true, 110 | ); 111 | 112 | assert.strictEqual( 113 | new ConventionalCommit("foo.txt").isCIRelated(), 114 | false, 115 | ); 116 | assert.strictEqual( 117 | new ConventionalCommit("fizz/foo.txt").isCIRelated(), 118 | false, 119 | ); 120 | }); 121 | 122 | it("can tell a CI change is in a workflows directory", function () { 123 | assert.strictEqual( 124 | new ConventionalCommit(".github/workflows/foo.txt").isCIRelated(), 125 | true, 126 | ); 127 | 128 | assert.strictEqual( 129 | new ConventionalCommit("foo.txt").isCIRelated(), 130 | false, 131 | ); 132 | assert.strictEqual( 133 | new ConventionalCommit(".github/foo.txt").isCIRelated(), 134 | false, 135 | ); 136 | }); 137 | 138 | it("can tell a CI change for a CI filename", function () { 139 | assert.strictEqual( 140 | new ConventionalCommit("netlify.toml").isCIRelated(), 141 | true, 142 | ); 143 | assert.strictEqual( 144 | new ConventionalCommit("foo/netlify.toml").isCIRelated(), 145 | true, 146 | ); 147 | 148 | assert.strictEqual( 149 | new ConventionalCommit("foo.txt").isCIRelated(), 150 | false, 151 | ); 152 | }); 153 | }); 154 | 155 | describe("#isTestRelated", function () { 156 | it("can tell a test directory is for tests", function () { 157 | assert.strictEqual( 158 | new ConventionalCommit("test/foo.js").isTestRelated(), 159 | true, 160 | ); 161 | assert.strictEqual( 162 | new ConventionalCommit("tests/foo.js").isTestRelated(), 163 | true, 164 | ); 165 | assert.strictEqual( 166 | new ConventionalCommit("spec/foo.js").isTestRelated(), 167 | true, 168 | ); 169 | 170 | assert.strictEqual( 171 | new ConventionalCommit("unit_tests/foo.js").isTestRelated(), 172 | true, 173 | ); 174 | }); 175 | 176 | it("can tell a test file is for tests", function () { 177 | assert.strictEqual( 178 | new ConventionalCommit("foo/bar.test.js").isTestRelated(), 179 | true, 180 | ); 181 | assert.strictEqual( 182 | new ConventionalCommit("foo/test_bar.js").isTestRelated(), 183 | true, 184 | ); 185 | }); 186 | }); 187 | 188 | describe("#getType", function () { 189 | // Rather than true and false like in above tests this actually categorizes 190 | // and also it closer to the real world as it through a hierarchy (for 191 | // example .yml is config-related unless it is for a CI file). But, this 192 | // doesn't care what the action is like create or delete or modify, so it 193 | // won't impose meaning based on that. 194 | it("sees a build file as build", function () { 195 | assert.strictEqual( 196 | new ConventionalCommit("Makefile").getType(), 197 | CONVENTIONAL_TYPE.BUILD, 198 | ); 199 | assert.strictEqual( 200 | new ConventionalCommit("Dockerfile").getType(), 201 | CONVENTIONAL_TYPE.BUILD, 202 | ); 203 | 204 | assert.strictEqual( 205 | new ConventionalCommit("foo.gemspec").getType(), 206 | CONVENTIONAL_TYPE.BUILD, 207 | ); 208 | 209 | assert.strictEqual( 210 | new ConventionalCommit("package.json").getType(), 211 | CONVENTIONAL_TYPE.BUILD, 212 | ); 213 | }); 214 | 215 | it("sees a dependency-related file as 'build' and with dependency scope", function () { 216 | assert.strictEqual( 217 | new ConventionalCommit("Gemfile").getType(), 218 | CONVENTIONAL_TYPE.BUILD_DEPENDENCIES, 219 | ); 220 | 221 | assert.strictEqual( 222 | new ConventionalCommit("package-lock.json").getType(), 223 | CONVENTIONAL_TYPE.BUILD_DEPENDENCIES, 224 | ); 225 | 226 | assert.strictEqual( 227 | new ConventionalCommit("requirements.txt").getType(), 228 | CONVENTIONAL_TYPE.BUILD_DEPENDENCIES, 229 | ); 230 | assert.strictEqual( 231 | new ConventionalCommit("requirements-dev.txt").getType(), 232 | CONVENTIONAL_TYPE.BUILD_DEPENDENCIES, 233 | ); 234 | }); 235 | 236 | // TODO: Break into categories 237 | it("can tell a type for other types", function () { 238 | assert.strictEqual( 239 | new ConventionalCommit("foo").getType(), 240 | CONVENTIONAL_TYPE.UNKNOWN, 241 | ); 242 | 243 | assert.strictEqual( 244 | new ConventionalCommit("test/foo.js").getType(), 245 | CONVENTIONAL_TYPE.TEST, 246 | ); 247 | 248 | assert.strictEqual( 249 | new ConventionalCommit(".github/workflows/foo.yml").getType(), 250 | CONVENTIONAL_TYPE.CI, 251 | ); 252 | 253 | assert.strictEqual( 254 | new ConventionalCommit("README.md").getType(), 255 | CONVENTIONAL_TYPE.DOCS, 256 | ); 257 | 258 | assert.strictEqual( 259 | new ConventionalCommit("LICENSE").getType(), 260 | CONVENTIONAL_TYPE.CHORE, 261 | ); 262 | }); 263 | }); 264 | }); 265 | 266 | describe("#getConventionType", function () { 267 | it("uses feat for a new file if no other match is found", function () { 268 | const add = ACTION.A; 269 | 270 | assert.strictEqual( 271 | getConventionType(add, "README.md"), 272 | CONVENTIONAL_TYPE.DOCS, 273 | ); 274 | assert.strictEqual( 275 | getConventionType(add, "tests/foo.js"), 276 | CONVENTIONAL_TYPE.TEST, 277 | ); 278 | 279 | assert.strictEqual( 280 | getConventionType(add, "foo.txt"), 281 | CONVENTIONAL_TYPE.FEAT, 282 | ); 283 | }); 284 | it("knows a deleted file is always a chore", function () { 285 | const del = ACTION.D; 286 | 287 | assert.strictEqual( 288 | getConventionType(del, "foo.txt"), 289 | CONVENTIONAL_TYPE.CHORE, 290 | ); 291 | assert.strictEqual( 292 | getConventionType(del, "README.md"), 293 | CONVENTIONAL_TYPE.CHORE, 294 | ); 295 | 296 | assert.strictEqual( 297 | getConventionType(del, "tests/foo.js"), 298 | CONVENTIONAL_TYPE.CHORE, 299 | ); 300 | }); 301 | 302 | it("knows a renamed or moved file is always chore", function () { 303 | const renameOrMove = ACTION.R; 304 | 305 | assert.strictEqual( 306 | getConventionType(renameOrMove, "foo.txt"), 307 | CONVENTIONAL_TYPE.CHORE, 308 | ); 309 | assert.strictEqual( 310 | getConventionType(renameOrMove, "fuzz/foo.txt"), 311 | CONVENTIONAL_TYPE.CHORE, 312 | ); 313 | 314 | assert.strictEqual( 315 | getConventionType(renameOrMove, "README.md"), 316 | CONVENTIONAL_TYPE.CHORE, 317 | ); 318 | assert.strictEqual( 319 | getConventionType(renameOrMove, "docs/foo.txt"), 320 | CONVENTIONAL_TYPE.CHORE, 321 | ); 322 | 323 | assert.strictEqual( 324 | getConventionType(renameOrMove, "tests/foo.js"), 325 | CONVENTIONAL_TYPE.CHORE, 326 | ); 327 | }); 328 | 329 | it("uses conventional commit type from path for a modified file, or leaves not set", function () { 330 | const modified = ACTION.M; 331 | 332 | assert.strictEqual( 333 | getConventionType(modified, "foo.txt"), 334 | CONVENTIONAL_TYPE.UNKNOWN, 335 | ); 336 | assert.strictEqual( 337 | getConventionType(modified, "fizz/foo.txt"), 338 | CONVENTIONAL_TYPE.UNKNOWN, 339 | ); 340 | 341 | assert.strictEqual( 342 | getConventionType(modified, "README.md"), 343 | CONVENTIONAL_TYPE.DOCS, 344 | ); 345 | assert.strictEqual( 346 | getConventionType(modified, "tests/foo.js"), 347 | CONVENTIONAL_TYPE.TEST, 348 | ); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /src/test/generate/message.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Message test module. 3 | * 4 | * High-level test of the message shown to the user, based on changes to one or more files. This 5 | * includes the action verb in a sentence, along with named files, but not the conventional commit 6 | * prefix. 7 | */ 8 | import * as assert from "assert"; 9 | import { namedFilesDesc, oneChange } from "../../generate/message"; 10 | 11 | describe("Generate commit message for a single changed file", function () { 12 | // Notes: 13 | // - The command `git status --short` expects `XY` format but this is for 14 | // `git diff-index` which is only `X`. Also there is just spaces between - 15 | // no '->' symbol. 16 | // - Impossible cases are not covered here, like renaming a file and the 17 | // name and path are unchanged, or including two file names for an add 18 | // line. But validation on at least file name is done. 19 | describe("#oneChange", function () { 20 | it("returns the appropriate commit message for a new file", function () { 21 | assert.strictEqual(oneChange("A\tfoo.txt"), "create foo.txt"); 22 | 23 | // TODO: Maybe 'create foo.txt in bar', if the dir is not too long? 24 | assert.strictEqual(oneChange("A\tbar/foo.txt"), "create foo.txt"); 25 | 26 | assert.strictEqual(oneChange("A\tfoo bar.txt"), "create 'foo bar.txt'"); 27 | assert.strictEqual( 28 | oneChange("A\tfizz buzz/foo bar.txt"), 29 | "create 'foo bar.txt'", 30 | ); 31 | }); 32 | 33 | it("throws an error if no file path can be no generated", function () { 34 | assert.throws(() => oneChange("A ")); 35 | }); 36 | 37 | it("returns the appropriate commit message for a modified file", function () { 38 | assert.strictEqual(oneChange("M\tfoo.txt"), "update foo.txt"); 39 | assert.strictEqual(oneChange("M\tbar/foo.txt"), "update foo.txt"); 40 | }); 41 | 42 | it("returns the appropriate commit message for a deleted file", function () { 43 | assert.strictEqual(oneChange("D\tfoo.txt"), "delete foo.txt"); 44 | assert.strictEqual(oneChange("D\tbar/foo.txt"), "delete foo.txt"); 45 | }); 46 | 47 | it("describes a file renamed in the same directory", function () { 48 | assert.strictEqual( 49 | oneChange("R\tfoo.txt\tbar.txt"), 50 | "rename foo.txt to bar.txt", 51 | ); 52 | 53 | assert.strictEqual( 54 | oneChange("R\tfizz/foo.txt\tfizz/bar.txt"), 55 | "rename foo.txt to bar.txt", 56 | ); 57 | }); 58 | 59 | it("ignores percentage change in a renamed file", function () { 60 | // We don't care about getting the percentage out in this project. So just 61 | // make sure it does get ignored. 62 | assert.strictEqual( 63 | oneChange("R97\tfoo.txt\tbar.txt"), 64 | "rename foo.txt to bar.txt", 65 | ); 66 | }); 67 | 68 | it("describes a file moved out of the repo root to another directory", function () { 69 | assert.strictEqual( 70 | oneChange("R\tfoo.txt\tfizz/foo.txt"), 71 | "move foo.txt to fizz", 72 | ); 73 | 74 | assert.strictEqual( 75 | oneChange("R\tfoo.txt\tfizz/buzz/foo.txt"), 76 | "move foo.txt to fizz/buzz", 77 | ); 78 | 79 | assert.strictEqual( 80 | oneChange("R\tfoo.txt\tfizz buzz/foo.txt"), 81 | "move foo.txt to 'fizz buzz'", 82 | ); 83 | 84 | assert.strictEqual( 85 | oneChange("R\tfoo bar.txt\tfizz/foo bar.txt"), 86 | "move 'foo bar.txt' to fizz", 87 | ); 88 | }); 89 | 90 | it("describes a file moved out of a subdirectory", function () { 91 | assert.strictEqual( 92 | oneChange("R\tfizz/buzz/foo.txt\tfoo.txt"), 93 | "move foo.txt to repo root", 94 | ); 95 | 96 | assert.strictEqual( 97 | oneChange("R\tfizz/buzz/foo.txt\tfizz/foo.txt"), 98 | "move foo.txt to fizz", 99 | ); 100 | 101 | assert.strictEqual( 102 | oneChange("R\tfizz/foo.txt\tfizz/buzz/foo.txt"), 103 | "move foo.txt to fizz/buzz", 104 | ); 105 | 106 | assert.strictEqual( 107 | oneChange("R\tfizz/foo bar.txt\tfizz/buzz baz/foo bar.txt"), 108 | "move 'foo bar.txt' to 'fizz/buzz baz'", 109 | ); 110 | }); 111 | 112 | it("describes a file that was both moved and renamed", function () { 113 | assert.strictEqual( 114 | oneChange("R\tfoo.txt\tfizz/fuzz.txt"), 115 | "move and rename foo.txt to fizz/fuzz.txt", 116 | ); 117 | 118 | assert.strictEqual( 119 | oneChange("R\tbar/foo.txt\tfuzz.txt"), 120 | "move and rename foo.txt to fuzz.txt at repo root", 121 | ); 122 | 123 | assert.strictEqual( 124 | oneChange("R\tbar/foo.txt\tfizz/fuzz.txt"), 125 | "move and rename foo.txt to fizz/fuzz.txt", 126 | ); 127 | 128 | assert.strictEqual( 129 | oneChange("R\tbar/foo.txt\tfizz/baz fuzz.txt"), 130 | "move and rename foo.txt to 'fizz/baz fuzz.txt'", 131 | ); 132 | }); 133 | 134 | it("ignores percentage changed value for a file that was both moved and renamed", function () { 135 | assert.strictEqual( 136 | oneChange("R97\tfoo.txt\tfizz/fuzz.txt"), 137 | "move and rename foo.txt to fizz/fuzz.txt", 138 | ); 139 | }); 140 | 141 | it("uses the full path to describe index files", function () { 142 | assert.strictEqual(oneChange("A\tREADME.md"), "create README.md"); 143 | assert.strictEqual(oneChange("M\tREADME.md"), "update README.md"); 144 | assert.strictEqual(oneChange("D\tREADME.md"), "delete README.md"); 145 | 146 | assert.strictEqual(oneChange("A\tfoo/README.md"), "create foo/README.md"); 147 | assert.strictEqual( 148 | oneChange("M\tbar/baz/README.md"), 149 | "update bar/baz/README.md", 150 | ); 151 | assert.strictEqual( 152 | oneChange("D\tbar/baz/buzz/README.md"), 153 | "delete bar/baz/buzz/README.md", 154 | ); 155 | 156 | assert.strictEqual(oneChange("A\tfoo/index.md"), "create foo/index.md"); 157 | assert.strictEqual( 158 | oneChange("A\tfoo/index bazz.md"), 159 | "create 'foo/index bazz.md'", 160 | ); 161 | assert.strictEqual(oneChange("A\tfoo/index.js"), "create foo/index.js"); 162 | }); 163 | }); 164 | }); 165 | 166 | describe("Generate description for a few changed files which each get named", function () { 167 | describe("#namedFilesDesc", function () { 168 | it("return the appropriate commit message for two files", function () { 169 | assert.strictEqual( 170 | namedFilesDesc([ 171 | { x: "A", from: "foo.txt", y: " ", to: "" }, 172 | { x: "A", from: "bar.txt", y: " ", to: "" }, 173 | ]), 174 | "create foo.txt and bar.txt", 175 | ); 176 | 177 | assert.strictEqual( 178 | namedFilesDesc([ 179 | { x: "A", from: "foo bar.txt", y: " ", to: "" }, 180 | { x: "A", from: "fizz buzz.txt", y: " ", to: "" }, 181 | ]), 182 | "create 'foo bar.txt' and 'fizz buzz.txt'", 183 | ); 184 | 185 | assert.strictEqual( 186 | namedFilesDesc([ 187 | { x: "M", from: "foo.txt", y: " ", to: "" }, 188 | { x: "M", from: "bar.txt", y: " ", to: "" }, 189 | ]), 190 | "update foo.txt and bar.txt", 191 | ); 192 | 193 | assert.strictEqual( 194 | namedFilesDesc([ 195 | { x: "M", from: "fizz.js", y: " ", to: "" }, 196 | { x: "M", from: "buzz.ts", y: " ", to: "" }, 197 | ]), 198 | "update fizz.js and buzz.ts", 199 | ); 200 | }); 201 | 202 | it("return a commit message for more than two files", function () { 203 | assert.strictEqual( 204 | namedFilesDesc([ 205 | { x: "A", from: "foo.txt", y: " ", to: "" }, 206 | { x: "A", from: "docs/bar.txt", y: " ", to: "" }, 207 | { x: "A", from: "buzz.js", y: " ", to: "" }, 208 | ]), 209 | "create foo.txt, bar.txt and buzz.js", 210 | ); 211 | 212 | assert.strictEqual( 213 | namedFilesDesc([ 214 | { x: "A", from: "foo.txt", y: " ", to: "" }, 215 | { x: "A", from: "docs/bar fuzz.txt", y: " ", to: "" }, 216 | { x: "A", from: "buzz.js", y: " ", to: "" }, 217 | ]), 218 | "create foo.txt, 'bar fuzz.txt' and buzz.js", 219 | ); 220 | 221 | assert.strictEqual( 222 | namedFilesDesc([ 223 | { x: "D", from: "foo.txt", y: " ", to: "" }, 224 | { x: "D", from: "docs/bar.txt", y: " ", to: "" }, 225 | { x: "D", from: "buzz.js", y: " ", to: "" }, 226 | ]), 227 | "delete foo.txt, bar.txt and buzz.js", 228 | ); 229 | }); 230 | 231 | it("handles differing actions", function () { 232 | assert.strictEqual( 233 | namedFilesDesc([ 234 | { x: "A", from: "foo.txt", y: " ", to: "" }, 235 | { x: "M", from: "bar.txt", y: " ", to: "" }, 236 | ]), 237 | "create 1 file and update 1 file", 238 | ); 239 | 240 | assert.strictEqual( 241 | namedFilesDesc([ 242 | { x: "M", from: "foo.txt", y: " ", to: "" }, 243 | { x: "D", from: "bar.txt", y: " ", to: "" }, 244 | ]), 245 | "update 1 file and delete 1 file", 246 | ); 247 | }); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /src/test/generate/parseExisting.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { 3 | splitMsg, 4 | _splitPrefixes, 5 | _splitPrefixesAndDesc, 6 | } from "../../generate/parseExisting"; 7 | 8 | describe("Split an existing message into components", function () { 9 | describe("#_splitPrefixesAndDesc", function () { 10 | it("handles a description alone", function () { 11 | const value = "foo the bar"; 12 | const expected = { 13 | prefixes: "", 14 | description: "foo the bar", 15 | }; 16 | assert.deepStrictEqual(_splitPrefixesAndDesc(value), expected); 17 | }); 18 | 19 | describe("splits correctly with prefix and description set", function () { 20 | it("handles a type prefix", function () { 21 | const value = "feat: foo the bar"; 22 | const expected = { 23 | prefixes: "feat", 24 | description: "foo the bar", 25 | }; 26 | assert.deepStrictEqual(_splitPrefixesAndDesc(value), expected); 27 | }); 28 | 29 | it("handles a custom prefix", function () { 30 | const value = "[ABC-123]: foo the bar"; 31 | const expected = { 32 | prefixes: "[ABC-123]", 33 | description: "foo the bar", 34 | }; 35 | assert.deepStrictEqual(_splitPrefixesAndDesc(value), expected); 36 | }); 37 | 38 | it("handles a custom prefix and type prefix", function () { 39 | const value = "[ABC-123] feat: foo the bar"; 40 | const expected = { 41 | prefixes: "[ABC-123] feat", 42 | description: "foo the bar", 43 | }; 44 | assert.deepStrictEqual(_splitPrefixesAndDesc(value), expected); 45 | }); 46 | }); 47 | }); 48 | 49 | describe("#_splitPrefixes", function () { 50 | it("returns empty values for an empty string", function () { 51 | assert.deepStrictEqual(_splitPrefixes(""), { 52 | customPrefix: "", 53 | typePrefix: "", 54 | }); 55 | }); 56 | 57 | it("returns a single word as the type prefix", function () { 58 | assert.deepStrictEqual(_splitPrefixes("foo"), { 59 | customPrefix: "", 60 | typePrefix: "foo", 61 | }); 62 | }); 63 | 64 | it("splits two words correctly as custom and type", function () { 65 | assert.deepStrictEqual(_splitPrefixes("foo bar"), { 66 | customPrefix: "foo", 67 | typePrefix: "bar", 68 | }); 69 | }); 70 | 71 | it("splits three words correctly as two for custom and one for type", function () { 72 | assert.deepStrictEqual(_splitPrefixes("foo bar bazz"), { 73 | customPrefix: "foo bar", 74 | typePrefix: "bazz", 75 | }); 76 | }); 77 | 78 | it("splits four words correctly as three for custom and one for type", function () { 79 | assert.deepStrictEqual(_splitPrefixes("foo bar bazz buzz"), { 80 | customPrefix: "foo bar bazz", 81 | typePrefix: "buzz", 82 | }); 83 | }); 84 | }); 85 | 86 | describe("#splitMsg", function () { 87 | it("handles a description alone", function () { 88 | assert.deepStrictEqual(splitMsg("abc def"), { 89 | customPrefix: "", 90 | typePrefix: "", 91 | description: "abc def", 92 | }); 93 | 94 | // Our logic can't turn the Jira number into a custom prefix if there is 95 | // no type prefix in the old message, so it just goes into the 96 | // description. 97 | // TODO: Handle hard bracket recognition or some other pattern here, as 98 | // then no logic is needed for commit message template (which VS Code can 99 | // already read). Or maybe just single word is enough. 100 | assert.deepStrictEqual(splitMsg("[ABCD-1234]"), { 101 | customPrefix: "", 102 | typePrefix: "", 103 | description: "[ABCD-1234]", 104 | }); 105 | assert.deepStrictEqual(splitMsg("[ABCD-1234] abc def"), { 106 | customPrefix: "", 107 | typePrefix: "", 108 | description: "[ABCD-1234] abc def", 109 | }); 110 | }); 111 | 112 | it("handles a type prefix alone", function () { 113 | assert.deepStrictEqual(splitMsg("docs:"), { 114 | customPrefix: "", 115 | typePrefix: "docs", 116 | description: "", 117 | }); 118 | assert.deepStrictEqual(splitMsg("feat:"), { 119 | customPrefix: "", 120 | typePrefix: "feat", 121 | description: "", 122 | }); 123 | 124 | assert.deepStrictEqual(splitMsg("docs: "), { 125 | customPrefix: "", 126 | typePrefix: "docs", 127 | description: "", 128 | }); 129 | }); 130 | 131 | it("separates a prefix and description", function () { 132 | assert.deepStrictEqual(splitMsg("docs: abc"), { 133 | customPrefix: "", 134 | typePrefix: "docs", 135 | description: "abc", 136 | }); 137 | assert.deepStrictEqual(splitMsg("docs: abc def"), { 138 | customPrefix: "", 139 | typePrefix: "docs", 140 | description: "abc def", 141 | }); 142 | assert.deepStrictEqual(splitMsg("feat: abc def"), { 143 | customPrefix: "", 144 | typePrefix: "feat", 145 | description: "abc def", 146 | }); 147 | }); 148 | 149 | describe("separates a custom prefix, conventional type, and description", function () { 150 | it("handles a Jira number with hard brackets", function () { 151 | assert.deepStrictEqual(splitMsg("[ABCD-1234] docs: abc def"), { 152 | customPrefix: "[ABCD-1234]", 153 | typePrefix: "docs", 154 | description: "abc def", 155 | }); 156 | }); 157 | 158 | it("handles a Jira number with no hard brackets", function () { 159 | assert.deepStrictEqual(splitMsg("ABCD-1234 docs: abc def"), { 160 | customPrefix: "ABCD-1234", 161 | typePrefix: "docs", 162 | description: "abc def", 163 | }); 164 | }); 165 | 166 | // TODO: 167 | // it("handles a two words before the type", function () { 168 | // assert.deepStrictEqual(splitMsg("ABCD 1234 docs: abc def"), { 169 | // customPrefix: "ABCD 1234", 170 | // typePrefix: "docs", 171 | // description: "abc def", 172 | // }); 173 | // }); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/test/git/parseOutput.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Git output test module. 3 | * 4 | * Check the ability to convert text output from Git subcommands into JS 5 | * objects. 6 | */ 7 | import * as assert from "assert"; 8 | import { parseDiffIndex, parseStatus } from "../../git/parseOutput"; 9 | import { FileChange } from "../../git/parseOutput.d"; 10 | 11 | describe("Split `git diff-index` output into components", function () { 12 | describe("#parseDiffIndex", function () { 13 | describe("states with a single path", function () { 14 | it("should return the appropriate commit message for a new file", function () { 15 | const expected: FileChange = { 16 | x: "A", 17 | y: " ", 18 | from: "foo.txt", 19 | to: "", 20 | }; 21 | 22 | assert.deepStrictEqual(parseDiffIndex("A\tfoo.txt"), expected); 23 | }); 24 | 25 | it("should return the appropriate commit message for a modified file", function () { 26 | const expected: FileChange = { 27 | x: "M", 28 | y: " ", 29 | from: "foo.txt", 30 | to: "", 31 | }; 32 | 33 | assert.deepStrictEqual(parseDiffIndex("M\tfoo.txt"), expected); 34 | }); 35 | 36 | it("should return the appropriate commit message for a deleted file", function () { 37 | const expected: FileChange = { 38 | x: "D", 39 | y: " ", 40 | from: "foo.txt", 41 | to: "", 42 | }; 43 | 44 | assert.deepStrictEqual(parseDiffIndex("D\tfoo.txt"), expected); 45 | }); 46 | }); 47 | 48 | describe("states with two paths", function () { 49 | it("should return the appropriate commit message for a renamed unchanged file", function () { 50 | const expected: FileChange = { 51 | x: "R", 52 | y: " ", 53 | from: "bar.txt", 54 | to: "foo.txt", 55 | }; 56 | 57 | assert.deepStrictEqual( 58 | parseDiffIndex("R100\tbar.txt\tfoo.txt"), 59 | expected, 60 | ); 61 | 62 | it("should return the appropriate commit message for a moved file", function () { 63 | const expected: FileChange = { 64 | x: "R", 65 | y: " ", 66 | from: "bar.txt", 67 | to: "fizz/foo.txt", 68 | }; 69 | 70 | assert.deepStrictEqual( 71 | parseDiffIndex("R100\tbar.txt\tfizz/foo.txt"), 72 | expected, 73 | ); 74 | }); 75 | }); 76 | 77 | it("returns a correct commit message for a renamed and modified file", function () { 78 | const expected: FileChange = { 79 | x: "R", 80 | y: " ", 81 | from: "bar.txt", 82 | to: "foo.txt", 83 | }; 84 | assert.deepStrictEqual( 85 | parseDiffIndex("R096\tbar.txt\tfoo.txt"), 86 | expected, 87 | ); 88 | 89 | it("should return the appropriate commit message for a moved file", function () { 90 | const expected: FileChange = { 91 | x: "R", 92 | y: " ", 93 | to: "bar.txt", 94 | from: "fizz/foo.txt", 95 | }; 96 | 97 | assert.deepStrictEqual( 98 | parseDiffIndex("R096\tbar.txt\tfizz/foo.txt"), 99 | expected, 100 | ); 101 | }); 102 | }); 103 | }); 104 | 105 | describe("handle paths with spaces in them", function () { 106 | // No quoting is needed here as that is only needed when formatting the 107 | // final message. 108 | 109 | it("should handle a single path correctly", function () { 110 | { 111 | const expected: FileChange = { 112 | x: "A", 113 | y: " ", 114 | from: "foo bar.txt", 115 | to: "", 116 | }; 117 | 118 | assert.deepStrictEqual(parseDiffIndex("A\tfoo bar.txt"), expected); 119 | } 120 | 121 | { 122 | const expected: FileChange = { 123 | x: "A", 124 | y: " ", 125 | from: "foo bar fizz buzz.txt", 126 | to: "", 127 | }; 128 | 129 | assert.deepStrictEqual( 130 | parseDiffIndex("A\tfoo bar fizz buzz.txt"), 131 | expected, 132 | ); 133 | } 134 | 135 | { 136 | const expected: FileChange = { 137 | x: "A", 138 | y: " ", 139 | from: "fizz/foo bar.txt", 140 | to: "", 141 | }; 142 | 143 | assert.deepStrictEqual( 144 | parseDiffIndex("A\tfizz/foo bar.txt"), 145 | expected, 146 | ); 147 | } 148 | 149 | { 150 | const expected: FileChange = { 151 | x: "A", 152 | y: " ", 153 | from: "fizz buzz/foo bar.txt", 154 | to: "", 155 | }; 156 | 157 | assert.deepStrictEqual( 158 | parseDiffIndex("A\tfizz buzz/foo bar.txt"), 159 | expected, 160 | ); 161 | } 162 | }); 163 | }); 164 | 165 | describe("handle special characters in filenames", function () { 166 | it("should correctly parse a filename with special characters", function () { 167 | const expected: FileChange = { 168 | x: "A", 169 | y: " ", 170 | from: "abcëxyz.md", 171 | to: "", 172 | }; 173 | 174 | assert.deepStrictEqual(parseDiffIndex("A\tabcëxyz.md"), expected); 175 | }); 176 | }); 177 | 178 | it("should handle a pair of paths correctly", function () { 179 | { 180 | const expected: FileChange = { 181 | x: "R", 182 | y: " ", 183 | from: "foo bar.txt", 184 | to: "fizz/foo bar.txt", 185 | }; 186 | 187 | assert.deepStrictEqual( 188 | parseDiffIndex("R100\tfoo bar.txt\tfizz/foo bar.txt"), 189 | expected, 190 | ); 191 | } 192 | 193 | { 194 | const expected: FileChange = { 195 | x: "R", 196 | y: " ", 197 | from: "foo bar.txt", 198 | to: "fizz buzz/foo bar.txt", 199 | }; 200 | 201 | assert.deepStrictEqual( 202 | parseDiffIndex("R100\tfoo bar.txt\tfizz buzz/foo bar.txt"), 203 | expected, 204 | ); 205 | } 206 | }); 207 | 208 | it("throws an error on input that is too short", function () { 209 | assert.throws(() => parseDiffIndex("abc")); 210 | }); 211 | }); 212 | }); 213 | 214 | // Not a core part of this extension anymore, but the code and tests are kept 215 | // anyway. 216 | describe("Split `git status` output into components", function () { 217 | describe("#parseStatus", function () { 218 | it("should return the appropriate commit message for a new file", function () { 219 | const expected: FileChange = { 220 | x: "A", 221 | y: " ", 222 | from: "foo.txt", 223 | to: "", 224 | }; 225 | 226 | assert.deepStrictEqual(parseStatus("A \tfoo.txt"), expected); 227 | }); 228 | 229 | it("should return the appropriate commit message for a modified file", function () { 230 | const expected: FileChange = { 231 | x: " ", 232 | y: "M", 233 | from: "foo.txt", 234 | to: "", 235 | }; 236 | 237 | assert.deepStrictEqual(parseStatus(" M\tfoo.txt"), expected); 238 | }); 239 | 240 | it("should return the appropriate commit message for a deleted file", function () { 241 | const expected: FileChange = { 242 | x: "D", 243 | y: " ", 244 | from: "foo.txt", 245 | to: "", 246 | }; 247 | 248 | assert.deepStrictEqual(parseStatus("D foo.txt"), expected); 249 | }); 250 | 251 | it("should return the appropriate commit message for a renamed file", function () { 252 | const expected: FileChange = { 253 | x: "R", 254 | y: " ", 255 | from: "foo.txt", 256 | to: "bar.txt", 257 | }; 258 | 259 | assert.deepStrictEqual(parseStatus("R foo.txt -> bar.txt"), expected); 260 | 261 | it("should return the appropriate commit message for a moved file", function () { 262 | const expected: FileChange = { 263 | x: "R", 264 | y: " ", 265 | from: "foo.txt", 266 | to: "fizz/foo.txt", 267 | }; 268 | 269 | assert.deepStrictEqual( 270 | parseStatus("R foo.txt -> fizz/foo.txt"), 271 | expected, 272 | ); 273 | }); 274 | }); 275 | 276 | it("throws an error on input that is too short", function () { 277 | assert.throws(() => parseStatus("abc")); 278 | }); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /src/test/lib/commonPath.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Common path test module. 3 | * 4 | * Check that a common path can be found for paths. 5 | */ 6 | import * as assert from "assert"; 7 | import { commonPath, _splitStrings } from "../../lib/commonPath"; 8 | 9 | describe("Split an array of strings at a separator", function () { 10 | describe("#_splitStrings", function () { 11 | it("should split 3 strings correctly with the default separator", function () { 12 | const items = ["a/b/c", "ABC/DEF/GHI", "1/2/3"]; 13 | 14 | const expected = [ 15 | ["a", "b", "c"], 16 | ["ABC", "DEF", "GHI"], 17 | ["1", "2", "3"], 18 | ]; 19 | 20 | assert.deepStrictEqual(_splitStrings(items), expected); 21 | }); 22 | }); 23 | }); 24 | 25 | describe("Find the highest common parent directory for paths", function () { 26 | // This is useful when building a change message about multiple files and 27 | // seeing what the high common level is between them so this can be used in 28 | // the message. If the parent directory is needed for that to keep it much 29 | // shorter, that is easy from the std lib. 30 | describe("#commonPath", function () { 31 | it("should give the common path for 3 root repo paths", function () { 32 | const paths = ["foo", "bar", "fizz/buzz"]; 33 | 34 | assert.strictEqual(commonPath(paths), "repo root"); 35 | }); 36 | 37 | // These are relative to the repo root but don't have a forward slash, 38 | // based on git output from status or diff-index. 39 | it("should give the common path for 2 different paths", function () { 40 | const paths = ["Foo/test", "Foo/bar/test"]; 41 | 42 | assert.strictEqual(commonPath(paths), "Foo"); 43 | }); 44 | 45 | it("should give the common path for 3 similar repo paths", function () { 46 | const paths = [ 47 | "fizz/buzz/coverage/test", 48 | "fizz/buzz/covert/operator", 49 | "fizz/buzz/tmp/coven/members", 50 | ]; 51 | 52 | assert.strictEqual(commonPath(paths), "fizz/buzz"); 53 | }); 54 | 55 | // This shouldn't matter for use in a repo but just check its robustness. 56 | it("should give the common path for 3 related absolute paths", function () { 57 | const paths = [ 58 | "/home/user1/tmp/coverage/test", 59 | "/home/user1/tmp/covert/operator", 60 | "/home/user1/tmp/coven/members", 61 | ]; 62 | 63 | assert.strictEqual(commonPath(paths), "/home/user1/tmp"); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/test/lib/paths.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Paths test module. 3 | * 4 | * Test handling of path values as text. 5 | */ 6 | import * as assert from "assert"; 7 | import { ROOT } from "../../lib/constants"; 8 | import { 9 | friendlyFile, 10 | humanList, 11 | quoteForSpaces, 12 | splitPath, 13 | _join, 14 | } from "../../lib/paths"; 15 | 16 | describe("Path handling", function () { 17 | describe("#splitPath", function () { 18 | it("splits a path correctly", function () { 19 | assert.deepStrictEqual(splitPath("baz.txt"), { 20 | atRoot: true, 21 | dirPath: ROOT, 22 | name: "baz.txt", 23 | extension: ".txt", 24 | }); 25 | 26 | assert.deepStrictEqual(splitPath("foo/bar/baz.txt"), { 27 | atRoot: false, 28 | dirPath: "foo/bar", 29 | name: "baz.txt", 30 | extension: ".txt", 31 | }); 32 | }); 33 | }); 34 | 35 | describe("#quoteForSpaces", function () { 36 | it("add quotes for values with spaces", function () { 37 | assert.strictEqual(quoteForSpaces("foo bar.txt"), "'foo bar.txt'"); 38 | 39 | assert.strictEqual( 40 | quoteForSpaces("fizz buzz/foo bar.txt"), 41 | "'fizz buzz/foo bar.txt'", 42 | ); 43 | }); 44 | 45 | it("returns the original value if there are no spaces", function () { 46 | assert.strictEqual(quoteForSpaces("fizz.txt"), "fizz.txt"); 47 | 48 | assert.strictEqual( 49 | quoteForSpaces("fizz-buzz/foo-bar.txt"), 50 | "fizz-buzz/foo-bar.txt", 51 | ); 52 | }); 53 | }); 54 | 55 | describe("#_join", function () { 56 | it("returns one item", function () { 57 | const result = _join(["foo"]); 58 | assert.strictEqual(result, "foo"); 59 | }); 60 | 61 | it('returns two items joined with "and"', function () { 62 | const result = _join(["foo", "bar"]); 63 | assert.strictEqual(result, "foo and bar"); 64 | }); 65 | 66 | it('returns three items joined with commands and an an "and"', function () { 67 | const result = _join(["foo", "bar", "bazz"]); 68 | assert.strictEqual(result, "foo, bar and bazz"); 69 | }); 70 | 71 | it("returns empty string for now items", function () { 72 | const result = _join([]); 73 | assert.strictEqual(result, ""); 74 | }); 75 | }); 76 | 77 | describe("#friendlyFile", function () { 78 | it("formats a long path as a filename only", function () { 79 | assert.strictEqual(friendlyFile("Baz.txt"), "Baz.txt"); 80 | assert.strictEqual(friendlyFile("bazz/Baz.txt"), "Baz.txt"); 81 | }); 82 | 83 | it("formats a README file as a full path", function () { 84 | assert.strictEqual(friendlyFile("README.md"), "README.md"); 85 | assert.strictEqual(friendlyFile("foo/README.md"), "foo/README.md"); 86 | assert.strictEqual(friendlyFile("bar/readme.txt"), "bar/readme.txt"); 87 | }); 88 | 89 | it("formats an index file as a full path", function () { 90 | assert.strictEqual(friendlyFile("Foo/index.md"), "Foo/index.md"); 91 | assert.strictEqual(friendlyFile("Foo/index.html"), "Foo/index.html"); 92 | assert.strictEqual(friendlyFile("Foo/index.js"), "Foo/index.js"); 93 | }); 94 | }); 95 | 96 | describe("#humanList", function () { 97 | it("returns a path for a single file", function () { 98 | assert.strictEqual(humanList(["foo.txt"]), "foo.txt"); 99 | }); 100 | 101 | it("returns a sentence for two files", function () { 102 | assert.strictEqual( 103 | humanList(["foo.txt", "bar.txt"]), 104 | "foo.txt and bar.txt", 105 | ); 106 | }); 107 | 108 | it("returns a sentence for three files", function () { 109 | assert.strictEqual( 110 | humanList(["foo.txt", "bar.txt", "bazz.js"]), 111 | "foo.txt, bar.txt and bazz.js", 112 | ); 113 | }); 114 | 115 | it("returns a sentence for four files", function () { 116 | assert.strictEqual( 117 | humanList(["foo.txt", "bar.txt", "bazz.js", "buzz.ts"]), 118 | "foo.txt, bar.txt, bazz.js and buzz.ts", 119 | ); 120 | }); 121 | 122 | it("returns a sentence for four longer paths", function () { 123 | const input = [ 124 | "foo.txt", 125 | "docs/README.md", 126 | "src/lib/bazz.js", 127 | "src/buzz.ts", 128 | ]; 129 | assert.strictEqual( 130 | humanList(input), 131 | "foo.txt, docs/README.md, bazz.js and buzz.ts", 132 | ); 133 | }); 134 | 135 | it("throws an error for zero files", function () { 136 | assert.throws(() => humanList([])); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/test/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { ACTION } from "../../lib/constants"; 3 | import { equal } from "../../lib/utils"; 4 | 5 | describe("Utility functions", function () { 6 | describe("#equal", function () { 7 | it("returns true for equal values", function () { 8 | assert.strictEqual(equal([1, 1]), true); 9 | assert.strictEqual(equal([ACTION.A, ACTION.A, ACTION.A]), true); 10 | }); 11 | 12 | it("returns value for different values", function () { 13 | assert.strictEqual(equal([1, 2]), false); 14 | assert.strictEqual(equal([ACTION.A, ACTION.A, ACTION.M]), false); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/workspace.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from "vscode"; 2 | 3 | export function getWorkspaceFolder(): string { 4 | const { workspaceFolders } = workspace; 5 | 6 | return workspaceFolders ? workspaceFolders[0].uri.fsPath : ""; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "es2019", 5 | "lib": [ 6 | "ES2019" 7 | ], 8 | "sourceMap": true, 9 | "strict": true, 10 | "rootDir": "src", 11 | "outDir": "out", 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | ".vscode-test", 16 | "sandbox" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-string-throw": true, 4 | "no-unused-expression": true, 5 | "no-duplicate-variable": true, 6 | "curly": true, 7 | "class-name": true, 8 | "semicolon": [true, "always"], 9 | "triple-equals": true 10 | }, 11 | "defaultSeverity": "warning" 12 | } 13 | --------------------------------------------------------------------------------