├── .npmignore ├── .husky └── pre-commit ├── docs ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── index.md ├── get-started.md └── get-involved.md ├── .vscode └── settings.json ├── .github ├── dependabot.yml └── workflows │ ├── compile.yml │ ├── test.yml │ ├── docs.yml │ └── publish-releases.yaml ├── src ├── slugify.test.ts ├── logger.ts ├── slugify.ts ├── completion.sh ├── repo-config.ts ├── human-age.ts ├── main-merge.ts ├── start-branch.ts ├── create-pr.ts ├── parent-branch.ts ├── origin-push.ts ├── get-back.ts ├── branch-utils.ts ├── shell-completion.ts ├── end-to-end.test.ts ├── storage.ts ├── find-branches.ts ├── branch-info.ts ├── configure-repo.ts ├── index.ts ├── github-utils.ts ├── github.ts └── commit-branch.ts ├── .gitignore ├── tsconfig.json ├── biome.json ├── LICENSE ├── justfile ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .husky/ 2 | .github/ 3 | out/ 4 | dummy* 5 | .DS_Store 6 | .vscode 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | bun test 3 | bun run lint 4 | git update-index --again 5 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/gg2/main/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [88], 3 | "editor.formatOnSave": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "biomejs.biome" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | open-pull-requests-limit: 20 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: monthly 13 | -------------------------------------------------------------------------------- /src/slugify.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test" 2 | import { slugifyTitleToBranchName } from "./slugify" 3 | 4 | describe("slugify title to branch name", () => { 5 | test("keeps forward slashes", () => { 6 | expect(slugifyTitleToBranchName("peterbe/CAP-123 Bla bla")).toBe( 7 | "peterbe/cap-123-bla-bla", 8 | ) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gg2", 3 | "short_name": "gg2", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import kleur from "kleur" 2 | 3 | export function success(...args: string[]) { 4 | console.log(...args.map((arg) => kleur.green(arg))) 5 | } 6 | export function error(...args: string[]) { 7 | console.error(...args.map((arg) => kleur.red(arg))) 8 | } 9 | 10 | export function warn(...args: string[]) { 11 | console.warn(...args.map((arg) => kleur.yellow(arg))) 12 | } 13 | -------------------------------------------------------------------------------- /src/slugify.ts: -------------------------------------------------------------------------------- 1 | export function slugifyTitleToBranchName(title: string): string { 2 | let slug = title.toLowerCase() 3 | 4 | // Replace spaces and special characters with hyphens 5 | slug = slug.replace(/[\s_]+/g, "-") // Replace spaces and underscores with hyphens 6 | // Remove non-alphanumeric characters except hyphens and slashes 7 | slug = slug.replace(/[^a-z0-9-/]/g, "") 8 | 9 | // Trim leading and trailing hyphens 10 | slug = slug.replace(/^-+|-+$/g, "") 11 | 12 | return slug 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | docs/.vitepress/dist 36 | docs/.vitepress/cache 37 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "gg2" 7 | text: "CLI tool for creating, committing, and generally managing your git branches." 8 | # tagline: My great project tagline 9 | actions: 10 | - theme: brand 11 | text: Get started 12 | link: /get-started 13 | - text: Get involved 14 | link: /get-involved 15 | 16 | # features: 17 | # - title: Feature A 18 | # details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 19 | # - title: Feature B 20 | # details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 21 | # - title: Feature C 22 | # details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 23 | --- 24 | 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/compile.yml: -------------------------------------------------------------------------------- 1 | name: Compile 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | compile: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | target: 15 | - target: bun-linux-arm64 16 | asset_name: gg-linux-arm64 17 | - target: bun-linux-x64 18 | asset_name: gg-linux-x64 19 | - target: bun-windows-x64 20 | asset_name: gg-windows-x64.exe 21 | - target: bun-darwin-arm64 22 | asset_name: gg-darwin-arm64 23 | - target: bun-darwin-x64 24 | asset_name: gg-darwin-x64 25 | 26 | steps: 27 | - uses: actions/checkout@v6 28 | - uses: oven-sh/setup-bun@v2 29 | - run: bun install 30 | - run: bun build src/index.ts --compile --minify --sourcemap --bytecode --target=${{ matrix.target.target }} --outfile out/${{ matrix.target.asset_name }} 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | - uses: oven-sh/setup-bun@v2 18 | - run: bun install 19 | - run: bun run lint:check 20 | - run: bun run tsc 21 | - run: bun run build 22 | - run: bun run build:linux-x64 23 | - name: Compile and install 24 | run: | 25 | set -e 26 | mkdir -p /home/runner/.local/bin 27 | mv out/gg-linux-x64 /home/runner/.local/bin/gg 28 | - name: Add local bin to PATH 29 | run: echo "/home/runner/.local/bin" >> $GITHUB_PATH 30 | - name: Test installed version 31 | run: | 32 | set -e 33 | which gg 34 | gg --version 35 | gg help 36 | - run: bun test 37 | 38 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | docs: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v6 24 | - uses: oven-sh/setup-bun@v2 25 | - run: bun install 26 | - run: bun run docs:build 27 | 28 | - name: Upload built docs 29 | if: github.event_name != 'pull_request' 30 | uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: docs/.vitepress/dist/ 33 | 34 | - name: Deploy to GitHub Pages 35 | if: github.event_name != 'pull_request' 36 | id: deployment 37 | uses: actions/deploy-pages@v4 38 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "includes": [ 11 | "**", 12 | "!.husky", 13 | "!out", 14 | "!public", 15 | "!bun.lock", 16 | "!.github", 17 | "!test-results", 18 | "!dummy*", 19 | "!docs/.vitepress/dist*", 20 | "!docs/.vitepress/cache*" 21 | ] 22 | }, 23 | "formatter": { 24 | "enabled": true, 25 | "indentStyle": "space" 26 | }, 27 | "linter": { 28 | "enabled": true, 29 | "rules": { 30 | "recommended": true 31 | } 32 | }, 33 | "javascript": { 34 | "formatter": { 35 | "quoteStyle": "double", 36 | "semicolons": "asNeeded" 37 | } 38 | }, 39 | "assist": { 40 | "enabled": true, 41 | "actions": { 42 | "source": { 43 | "organizeImports": "on" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/completion.sh: -------------------------------------------------------------------------------- 1 | _generate_foo_completions() { 2 | local idx=$1; shift 3 | local words=( "$@" ) 4 | local current_word=${words[idx]} 5 | 6 | gg shell-completion --list "${words}" 7 | } 8 | 9 | _complete_foo_bash() { 10 | local IFS=$'\n' 11 | local raw=($(gg shell-completion --list "$COMP_LINE")) 12 | local trimmed=() 13 | trimmed+=( "${raw[@]}" ) 14 | 15 | if (( ${#raw[@]} == 1 )); then 16 | trimmed+=( "${raw[0]%%:*}" ) 17 | fi 18 | 19 | COMPREPLY=( "${trimmed[@]}" ) 20 | } 21 | 22 | _complete_foo_zsh() { 23 | local -a raw trimmed 24 | local IFS=$'\n' 25 | raw=($(_generate_foo_completions "$CURRENT" "${words[@]}")) 26 | 27 | for d in $raw; do trimmed+=( "${d%%:*}" ); done 28 | if (( ${#raw} == 1 )); then 29 | trimmed+=( "${raw[1]}" ) 30 | raw+=( "${trimmed[1]}" ) 31 | fi 32 | 33 | compadd -d raw -- $trimmed 34 | } 35 | 36 | if [ -n "${ZSH_VERSION:-}" ]; then 37 | autoload -Uz compinit 38 | compinit 39 | compdef _complete_foo_zsh gg 40 | elif [ -n "${BASH_VERSION:-}" ]; then 41 | complete -F _complete_foo_bash gg 42 | fi -------------------------------------------------------------------------------- /src/repo-config.ts: -------------------------------------------------------------------------------- 1 | import kleur from "kleur" 2 | 3 | import { getRepoConfig } from "./storage" 4 | 5 | export async function repoConfig() { 6 | const config = await getRepoConfig() 7 | 8 | const longestKey = Math.max(...Object.keys(config).map((key) => key.length)) 9 | const padding = Math.max(30, longestKey) + 1 10 | if (Object.keys(config).length === 0) { 11 | console.log(kleur.italic("No configuration found for this repo")) 12 | } else { 13 | const header = `${kleur.bold("KEY".padEnd(padding, " "))} ${kleur.bold("VALUE")}` 14 | console.log(header) 15 | console.log( 16 | kleur.dim("-".repeat("KEY".length + padding + "VALUE".length + 5)), 17 | ) 18 | for (const [key, value] of Object.entries(config)) { 19 | console.log( 20 | kleur.bold(`${key}:`.padEnd(padding, " ")), 21 | formatValue(value), 22 | ) 23 | } 24 | } 25 | } 26 | 27 | function formatValue(value: string | boolean): string { 28 | if (typeof value === "boolean") { 29 | return value ? kleur.green("true") : kleur.red("false") 30 | } 31 | return kleur.italic(JSON.stringify(value)) 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2026 Peter Bengtsson 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 | -------------------------------------------------------------------------------- /src/human-age.ts: -------------------------------------------------------------------------------- 1 | export function getHumanAge(date: Date): string { 2 | const now = new Date() 3 | const diffMs = now.getTime() - date.getTime() 4 | 5 | // Handle future dates 6 | if (diffMs < 0) { 7 | return "in the future" 8 | } 9 | 10 | const seconds = Math.floor(diffMs / 1000) 11 | const minutes = Math.floor(seconds / 60) 12 | const hours = Math.floor(minutes / 60) 13 | const days = Math.floor(hours / 24) 14 | const weeks = Math.floor(days / 7) 15 | const months = Math.floor(days / 30.44) // Average days per month 16 | const years = Math.floor(days / 365.25) // Account for leap years 17 | 18 | if (years > 0) { 19 | return `${years} year${years === 1 ? "" : "s"}` 20 | } 21 | 22 | if (months > 0) { 23 | return `${months} month${months === 1 ? "" : "s"}` 24 | } 25 | 26 | if (weeks > 0) { 27 | return `${weeks} week${weeks === 1 ? "" : "s"}` 28 | } 29 | 30 | if (days > 0) { 31 | return `${days} day${days === 1 ? "" : "s"}` 32 | } 33 | 34 | if (hours > 0) { 35 | return `${hours} hour${hours === 1 ? "" : "s"}` 36 | } 37 | 38 | if (minutes > 0) { 39 | return `${minutes} minute${minutes === 1 ? "" : "s"}` 40 | } 41 | 42 | return `${seconds} second${seconds === 1 ? "" : "s"}` 43 | } 44 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # https://github.com/casey/just 2 | # https://just.systems/ 3 | 4 | dev: 5 | echo "This will constantly build and copy executable to ~/bin/gg" 6 | bun build --watch src/index.ts --target=bun --outfile ~/bin/gg --compile 7 | 8 | build: 9 | bun run build 10 | 11 | ship: build 12 | cp out/gg ~/bin/gg 13 | 14 | linux-build: 15 | bun build src/index.ts --target=bun --outfile ~/Desktop/gg-linux-x64-modern --compile --minify --sourcemap --bytecode --target=bun-linux-x64-modern 16 | bun build src/index.ts --target=bun --outfile ~/Desktop/gg-linux-arm64 --compile --minify --sourcemap --bytecode --target=bun-linux-arm64 17 | 18 | linux-dev: 19 | bun build --watch src/index.ts --target=bun --outfile ~/Desktop/gg-linux-arm64 --compile --minify --sourcemap --bytecode --target=bun-linux-arm64 20 | 21 | lint: 22 | bun run lint:check 23 | 24 | format: 25 | bun run lint 26 | 27 | install: 28 | bun install 29 | 30 | outdated: 31 | bun outdated 32 | 33 | test: 34 | bun test 35 | 36 | release: 37 | bun run release 38 | 39 | upgrade: 40 | bun update --interactive 41 | bunx biome migrate --write 42 | 43 | docs-dev: 44 | bun run docs:dev 45 | 46 | docs: 47 | bun run docs:build 48 | bun run docs:preview 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@peterbe/gg", 3 | "module": "index.ts", 4 | "version": "0.0.13", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/peterbe/gg2" 9 | }, 10 | "scripts": { 11 | "lint": "biome check --write --unsafe", 12 | "lint:check": "biome check", 13 | "build": "bun build src/index.ts --target=bun --outfile out/gg --compile", 14 | "build:linux-arm64": "bun build src/index.ts --target=bun-linux-arm64 --outfile out/gg-linux-arm64 --compile", 15 | "build:linux-x64": "bun build src/index.ts --target=bun-linux-x64 --outfile out/gg-linux-x64 --compile", 16 | "prepare": "husky", 17 | "test": "bun test", 18 | "release": "np --yolo", 19 | "postinstall": "bunx biome migrate --write", 20 | "docs:dev": "vitepress dev docs", 21 | "docs:build": "vitepress build docs", 22 | "docs:preview": "vitepress preview docs" 23 | }, 24 | "devDependencies": { 25 | "@biomejs/biome": "2.3.8", 26 | "@types/bun": "1.3.4", 27 | "husky": "^9.1.7", 28 | "np": "^10.2.0", 29 | "vitepress": "^2.0.0-alpha.15" 30 | }, 31 | "peerDependencies": { 32 | "typescript": "^5.9.3" 33 | }, 34 | "dependencies": { 35 | "@inquirer/prompts": "^8.0.2", 36 | "commander": "^14.0.1", 37 | "fuzzysort": "^3.1.0", 38 | "kleur": "^4.1.5", 39 | "octokit": "^5.0.3", 40 | "simple-git": "^3.28.0" 41 | }, 42 | "publishConfig": { 43 | "access": "public" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/publish-releases.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | publish-releases: 13 | name: Publish binaries 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | target: 19 | - target: bun-linux-arm64 20 | asset_name: gg-linux-arm64 21 | - target: bun-linux-x64 22 | asset_name: gg-linux-x64 23 | - target: bun-windows-x64 24 | asset_name: gg-windows-x64.exe 25 | - target: bun-darwin-arm64 26 | asset_name: gg-darwin-arm64 27 | - target: bun-darwin-x64 28 | asset_name: gg-darwin-x64 29 | 30 | steps: 31 | - uses: actions/checkout@v6 32 | - uses: oven-sh/setup-bun@v2 33 | - run: bun install 34 | - name: Build 35 | run: | 36 | bun build src/index.ts --compile --minify --sourcemap --bytecode --target=${{ matrix.target.target }} --outfile out/${{ matrix.target.asset_name }} 37 | 38 | - name: Upload binaries to release 39 | uses: svenstaro/upload-release-action@v2 40 | with: 41 | repo_token: ${{ secrets.GITHUB_TOKEN }} 42 | file: out/${{ matrix.target.asset_name }} 43 | asset_name: ${{ matrix.target.asset_name }} 44 | tag: ${{ github.ref }} 45 | # overwrite: true 46 | # body: "Binary release for ${{ matrix.target.target }}" -------------------------------------------------------------------------------- /src/main-merge.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from "@inquirer/prompts" 2 | import simpleGit from "simple-git" 3 | import { getCurrentBranch, getDefaultBranch } from "./branch-utils" 4 | import { success } from "./logger" 5 | import { getUpstreamName } from "./storage" 6 | 7 | export async function mainMerge() { 8 | const git = simpleGit() 9 | const currentBranch = await getCurrentBranch(git) 10 | 11 | const defaultBranch = await getDefaultBranch(git) 12 | if (defaultBranch === currentBranch) { 13 | throw new Error(`You are on the default branch (${defaultBranch}) already.`) 14 | } 15 | 16 | const status = await git.status() 17 | if (!status.isClean()) { 18 | throw new Error("Current branch is not in a clean state. Run `git status`") 19 | } 20 | 21 | const upstreamName = await getUpstreamName() 22 | 23 | const remotes = await git.getRemotes(true) // true includes URLs 24 | const origin = remotes.find((remote) => remote.name === upstreamName) 25 | // const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 26 | if (!origin?.name) { 27 | throw new Error(`Could not find a remote called '${upstreamName}'`) 28 | } 29 | const originName = origin.name 30 | 31 | await git.fetch(originName, defaultBranch) 32 | 33 | await git.mergeFromTo(originName, defaultBranch) 34 | 35 | success( 36 | `Latest ${originName}/${defaultBranch} branch merged into this branch.`, 37 | ) 38 | 39 | let pushToRemote = false 40 | if (!pushToRemote && origin) { 41 | pushToRemote = await confirm({ 42 | message: `Push to ${originName}:`, 43 | default: false, 44 | }) 45 | } 46 | 47 | if (pushToRemote) { 48 | await git.push(upstreamName, currentBranch) 49 | success(`Changes pushed to ${originName}/${currentBranch}`) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/start-branch.ts: -------------------------------------------------------------------------------- 1 | import { input } from "@inquirer/prompts" 2 | import simpleGit from "simple-git" 3 | import { getCurrentBranch } from "./branch-utils" 4 | import { success } from "./logger" 5 | import { slugifyTitleToBranchName } from "./slugify" 6 | import { getRepoConfig, storeNewBranch } from "./storage" 7 | 8 | type Options = { [k: string]: never } 9 | 10 | export async function startBranch( 11 | url: string[] | undefined, 12 | _options: Options, 13 | ) { 14 | const git = simpleGit() 15 | const currentBranch = await getCurrentBranch(git) 16 | const title = await getTitle(url) 17 | let branchName = slugifyTitleToBranchName(title) 18 | const config = await getRepoConfig() 19 | if ( 20 | config["branch-prefix"] && 21 | !branchName.startsWith(config["branch-prefix"] as string) 22 | ) { 23 | branchName = `${config["branch-prefix"]}${branchName}` 24 | } 25 | 26 | await git.checkoutLocalBranch(branchName) 27 | success(`Created new branch: ${branchName}`) 28 | await storeNewBranch(branchName, { title, baseBranch: currentBranch }) 29 | } 30 | 31 | async function getTitle(urlOrTitle: string[] | undefined): Promise { 32 | if (urlOrTitle && urlOrTitle.length > 0) { 33 | // If it looks like a URL, it's not implemented yet 34 | if ( 35 | (urlOrTitle.length === 1 && URL.canParse(urlOrTitle[0] as string)) || 36 | (urlOrTitle.length === 1 && isInt(urlOrTitle[0] as string)) 37 | ) { 38 | throw new Error("Not implemented yet: parsing the URL to get the title") 39 | } 40 | return urlOrTitle.join(" ") 41 | } 42 | const config = await getRepoConfig() 43 | const titlePrefix = config["title-prefix"] 44 | const title = await input({ 45 | message: "Title:", 46 | default: titlePrefix ? (titlePrefix as string) : undefined, 47 | prefill: "editable", 48 | }) 49 | return title 50 | } 51 | 52 | function isInt(value: string) { 53 | if (Number.isNaN(value)) { 54 | return false 55 | } 56 | var x = parseFloat(value) 57 | return (x | 0) === x 58 | } 59 | -------------------------------------------------------------------------------- /docs/get-started.md: -------------------------------------------------------------------------------- 1 | # Get started 2 | 3 | To use `gg2` on your terminal you have to install it and add it to you `$PATH`. 4 | Like any other CLI tool. 5 | 6 | ## macOS 7 | 8 | Install it with: 9 | 10 | ```bash 11 | brew tap peterbe/gg2 12 | brew install gg2 13 | ``` 14 | 15 | Then configure bash completion. In your `~/.bashrc` or `~/.zshrc`, depending on what 16 | you use, append this: 17 | 18 | ```sh 19 | source <(gg2 shell-completion) 20 | ``` 21 | 22 | Now type, to test if it worked: 23 | 24 | ```bash 25 | gg2 26 | ``` 27 | 28 | ## Linux 29 | 30 | You can use [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) too. 31 | But let's focus on how to download the binary manually. 32 | 33 | 1. Go to 34 | 1. Look for the latest version (usually the topmost) 35 | 1. Click to download the file `gg-linux-arm64` or `gg-linux-x64` file depending on your CPU 36 | 1. Move the downloaded binary file to somewhere on your `$PATH` 37 | 38 | To know what a good place to put it is, use: 39 | 40 | ```bash 41 | echo $PATH 42 | ``` 43 | 44 | and look for a directory that is available to the user. 45 | 46 | For example: 47 | 48 | ```bash 49 | wget https://github.com/peterbe/gg2/releases/download/v0.0.13/gg-linux-arm64 50 | mv gg-linux-arm64 ~/.local/bin/gg2 51 | ``` 52 | 53 | Then configure bash completion. In your `~/.bashrc` or `~/.zshrc`, depending on what 54 | you use, append this: 55 | 56 | ```sh 57 | source <(gg2 shell-completion) 58 | ``` 59 | 60 | Now type, to test if it worked: 61 | 62 | ```bash 63 | gg2 64 | ``` 65 | 66 | ## From source 67 | 68 | To do this you need `Bun`. 69 | 70 | ```bash 71 | bun run build 72 | ``` 73 | 74 | That'll create an executable called `out/gg` by default. Test it with: 75 | 76 | ```bash 77 | ./out/gg --help 78 | ``` 79 | 80 | Now, move this executable somewhere on your `$PATH`. For example: 81 | 82 | ```bash 83 | mv out/gg ~/.local/bin/gg2 84 | ``` 85 | 86 | ```sh 87 | source <(gg2 shell-completion) 88 | ``` 89 | 90 | Now type, to test if it worked: 91 | 92 | ```bash 93 | gg2 94 | ``` 95 | -------------------------------------------------------------------------------- /src/create-pr.ts: -------------------------------------------------------------------------------- 1 | import { input } from "@inquirer/prompts" 2 | import kleur from "kleur" 3 | import simpleGit from "simple-git" 4 | import { getCurrentBranch, getDefaultBranch } from "./branch-utils" 5 | import { createGitHubPR, findPRByBranchName } from "./github-utils" 6 | import { warn } from "./logger" 7 | import { getBaseBranch, getTitle } from "./storage" 8 | 9 | type PROptions = { 10 | enableAutoMerge?: boolean 11 | } 12 | 13 | export async function createPR(options: PROptions) { 14 | const enableAutoMerge = Boolean(options.enableAutoMerge) 15 | 16 | if (enableAutoMerge) { 17 | throw new Error("Not implemented yet") 18 | } 19 | 20 | const git = simpleGit() 21 | const currentBranch = await getCurrentBranch(git) 22 | const defaultBranch = await getDefaultBranch(git) 23 | if (defaultBranch === currentBranch) { 24 | throw new Error( 25 | `You are on the default branch (${defaultBranch}). Switch to a feature branch first.`, 26 | ) 27 | } 28 | 29 | const status = await git.status() 30 | if (!status.isClean()) { 31 | throw new Error("Current branch is not in a clean state. Run `git status`") 32 | } 33 | 34 | const pr = await findPRByBranchName(currentBranch) 35 | if (pr) { 36 | warn("There is already a PR for this branch.") 37 | console.log(kleur.bold(pr.html_url)) 38 | return 39 | } 40 | 41 | const storedTitle = await getTitle(currentBranch) 42 | const message = "Title:" 43 | const title = await input({ message, default: storedTitle }) 44 | const storedBaseBranch = await getBaseBranch(currentBranch) 45 | const baseBranch = 46 | storedBaseBranch && storedBaseBranch !== defaultBranch 47 | ? await input({ message: "Base branch:", default: storedBaseBranch }) 48 | : defaultBranch 49 | 50 | const data = await createGitHubPR({ 51 | head: currentBranch, 52 | base: baseBranch, 53 | title, 54 | body: "", 55 | draft: false, 56 | }) 57 | console.log("Pull request created:") 58 | console.log(kleur.bold().green(data.html_url)) 59 | console.log("(⌘-click to open URLs)") 60 | } 61 | -------------------------------------------------------------------------------- /docs/get-involved.md: -------------------------------------------------------------------------------- 1 | # Get involved 2 | 3 | Suppose that you need it to work in a particular way that might not be intuitive 4 | for everyone, you can always make it a feature that is off by default with a 5 | configuration choice. 6 | 7 | ## File feature requests and bug reports 8 | 9 | Number one thing to do is to engage in a discussion: 10 | 11 | ## Local dev 12 | 13 | You need [Bun](https://bun.sh/) and [just](https://github.com/casey/just). And `git` 14 | of course. 15 | 16 | Get started with: 17 | 18 | ```bash 19 | bun install 20 | ``` 21 | 22 | You can run any command with: 23 | 24 | ```bash 25 | bun run src/index.ts 26 | ``` 27 | 28 | for example, 29 | 30 | ```bash 31 | bun run src/index.ts --help 32 | ``` 33 | 34 | or, to run the configuration 35 | 36 | ```bash 37 | bun run src/index.ts configure 38 | ``` 39 | 40 | Another practical way is to run `just build` which will generate a new `./out/gg` 41 | executable. You can symlink to make this executable the one you're using. 42 | For example: 43 | 44 | ```bash 45 | just build 46 | ln -s out/gg ~/bin/gg2 47 | which gg2 48 | gg2 --help 49 | ``` 50 | 51 | Once you've set that up, you can use `just dev`, which continually builds a 52 | new `./out/gg`. That way, you can have two terminals open side by side (building 53 | on the instructions about symlink above). 54 | In one terminal: 55 | 56 | ```bash 57 | just dev 58 | ``` 59 | 60 | In another terminal: 61 | 62 | ```bash 63 | gg2 --help 64 | ``` 65 | 66 | This way, as soon as you save a change to a `.ts` file, it becomes immediately 67 | usable in your executable. 68 | 69 | ## Testing and linting 70 | 71 | All code formatting is automated and entirely handled by `biome`. To lint *and* format 72 | run: 73 | 74 | ```bash 75 | just format 76 | ``` 77 | 78 | If it fails, it probably means you need to manually address something. 79 | 80 | To test, best is to run `just build` but to run the (limited) end-to-end tests, run: 81 | 82 | ```bash 83 | just test 84 | ``` 85 | 86 | ## Documentation 87 | 88 | To update the documentation (this!), you can run: 89 | 90 | ```bash 91 | just docs-dev 92 | ``` 93 | 94 | and open `http://localhost:5173/` which reloads automatically when you edit the 95 | `.md` files. 96 | -------------------------------------------------------------------------------- /src/parent-branch.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from "@inquirer/prompts" 2 | import kleur from "kleur" 3 | import simpleGit from "simple-git" 4 | import { getCurrentBranch } from "./branch-utils" 5 | import { success, warn } from "./logger" 6 | 7 | type Options = { 8 | yes?: boolean 9 | } 10 | 11 | export async function parentBranch(options: Options) { 12 | const yes = Boolean(options.yes) 13 | const git = simpleGit() 14 | const currentBranch = await getCurrentBranch(git) 15 | 16 | const outputFromGit = ( 17 | await simpleGit({ trimmed: true }).raw("show-branch", "-a") 18 | ).split("\n") 19 | const rev = await simpleGit({ trimmed: true }).raw( 20 | "rev-parse", 21 | "--abbrev-ref", 22 | "HEAD", 23 | ) 24 | 25 | // Got this from https://github.com/steveukx/git-js/issues/955#issuecomment-1817787639 26 | const parents = outputFromGit 27 | .map((line) => line.replace(/\].*/, "")) // remove branch commit message 28 | .filter((line) => line.includes("*")) // only lines with a star in them 29 | .filter((line) => !line.includes(rev)) // only lines not including the specified revision 30 | .filter((_line, index, all) => index < all.length - 1) // not the last line 31 | .map((line) => line.replace(/^.*\[/, "")) // remove all but the branch name 32 | 33 | if (parents.length >= 1 && parents[0]) { 34 | const parentBranch = parents[0] 35 | console.log( 36 | `Parent branch for ${currentBranch} is: ${kleur.bold().green(parentBranch)}`, 37 | ) 38 | console.log("") 39 | console.log( 40 | kleur.italic( 41 | "To update the current branch, my merging in this parent branch, use: gg updatebranch", 42 | ), 43 | ) 44 | console.log("") 45 | 46 | const checkOut = 47 | yes || 48 | (await confirm({ 49 | message: `Check out (${parentBranch}):`, 50 | default: true, 51 | })) 52 | const status = await git.status() 53 | if (!status.isClean()) { 54 | throw new Error( 55 | "Current branch is not in a clean state. Run `git status`", 56 | ) 57 | } 58 | 59 | if (checkOut) { 60 | await git.checkout(parentBranch) 61 | success(`Checked out branch ${kleur.bold(parentBranch)}`) 62 | } 63 | } else { 64 | warn("Unable to find any parents") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/origin-push.ts: -------------------------------------------------------------------------------- 1 | import kleur from "kleur" 2 | import simpleGit from "simple-git" 3 | import { 4 | getCurrentBranch, 5 | getDefaultBranch, 6 | getUnstagedFiles, 7 | getUntrackedFiles, 8 | } from "./branch-utils" 9 | import { findPRByBranchName, getGitHubNWO } from "./github-utils" 10 | import { success, warn } from "./logger" 11 | import { getBaseBranch, getUpstreamName } from "./storage" 12 | 13 | export async function originPush() { 14 | const git = simpleGit() 15 | const currentBranch = await getCurrentBranch(git) 16 | 17 | const defaultBranch = await getDefaultBranch(git) 18 | if (defaultBranch === currentBranch) { 19 | throw new Error(`Can't commit when on the ${defaultBranch} branch.`) 20 | } 21 | 22 | const status = await git.status() 23 | if (!status.isClean()) { 24 | // Is it only untracked files? If so, we can ignore them 25 | const untrackedFiles = await getUntrackedFiles(git) 26 | const unstagedFiles = await getUnstagedFiles(git) 27 | if (unstagedFiles.length > 0) { 28 | throw new Error( 29 | "Current branch is not in a clean state. Run `git status`", 30 | ) 31 | } else if (untrackedFiles.length > 0) { 32 | warn( 33 | `There are ${untrackedFiles.length} untracked file${untrackedFiles.length > 1 ? "s" : ""}. Going to ignore that`, 34 | ) 35 | } 36 | } 37 | 38 | const upstreamName = await getUpstreamName() 39 | 40 | const remotes = await git.getRemotes(true) // true includes URLs 41 | const origin = remotes.find((remote) => remote.name === upstreamName) 42 | const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 43 | if (!origin?.name) { 44 | throw new Error(`Could not find a remote called '${upstreamName}'`) 45 | } 46 | const originName = origin.name 47 | 48 | await git.push(upstreamName, currentBranch) 49 | success(`Changes pushed to ${originName}/${currentBranch}`) 50 | 51 | const nwo = originUrl && getGitHubNWO(originUrl) 52 | if (nwo) { 53 | const pr = await findPRByBranchName(currentBranch) 54 | 55 | console.log("") // just a spacer 56 | if (pr) { 57 | console.log(kleur.bold(pr.html_url)) 58 | } else { 59 | let url: string 60 | const storedBaseBranch = await getBaseBranch(currentBranch) 61 | if (storedBaseBranch && storedBaseBranch !== defaultBranch) { 62 | url = `https://github.com/${nwo}/compare/${storedBaseBranch}...${currentBranch}?expand=1` 63 | } else { 64 | url = `https://github.com/${nwo}/pull/new/${currentBranch}` 65 | } 66 | console.log(kleur.bold().green(url)) 67 | } 68 | console.log("(⌘-click to open URLs)") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/get-back.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from "@inquirer/prompts" 2 | import simpleGit, { type SimpleGit } from "simple-git" 3 | import { 4 | getCurrentBranch, 5 | getDefaultBranch, 6 | getUnstagedFiles, 7 | getUntrackedFiles, 8 | } from "./branch-utils" 9 | import { success, warn } from "./logger" 10 | import { getBaseBranch, getUpstreamName } from "./storage" 11 | 12 | type Options = { 13 | yes?: boolean 14 | } 15 | export async function getBack(options: Options) { 16 | const yes = Boolean(options.yes) 17 | const git = simpleGit() 18 | const currentBranch = await getCurrentBranch(git) 19 | 20 | const defaultBranch = await getDefaultBranch(git) 21 | if (defaultBranch === currentBranch) { 22 | throw new Error(`You are on the default branch (${defaultBranch}) already.`) 23 | } 24 | 25 | const status = await git.status() 26 | if (!status.isClean()) { 27 | // Is it only untracked files? If so, we can ignore them 28 | const untrackedFiles = await getUntrackedFiles(git) 29 | const unstagedFiles = await getUnstagedFiles(git) 30 | if (unstagedFiles.length > 0) { 31 | throw new Error( 32 | "Current branch is not in a clean state. Run `git status`", 33 | ) 34 | } else if (untrackedFiles.length > 0) { 35 | warn( 36 | `There are ${untrackedFiles.length} untracked file${untrackedFiles.length > 1 ? "s" : ""}. Going to ignore that`, 37 | ) 38 | } 39 | } 40 | const storedBaseBranch = await getBaseBranch(currentBranch) 41 | 42 | await git.checkout(storedBaseBranch || defaultBranch) 43 | 44 | const upstreamName = await getUpstreamName() 45 | 46 | const remotes = await git.getRemotes(true) 47 | const origin = remotes.find((remote) => remote.name === upstreamName) 48 | if (origin) { 49 | await git.pull(origin.name, defaultBranch) 50 | } else { 51 | await git.pull() 52 | } 53 | 54 | await deleteLocalBranch({ git, currentBranch, defaultBranch, yes }) 55 | } 56 | 57 | export async function deleteLocalBranch({ 58 | git, 59 | defaultBranch, 60 | currentBranch, 61 | yes, 62 | }: { 63 | git: SimpleGit 64 | defaultBranch: string 65 | currentBranch: string 66 | yes: boolean 67 | }) { 68 | try { 69 | await git.deleteLocalBranch(currentBranch) 70 | } catch (error) { 71 | if ( 72 | error instanceof Error && 73 | error.message.includes( 74 | "If you are sure you want to delete it, run 'git branch -D", 75 | ) 76 | ) { 77 | if (!yes) { 78 | warn(`Doesn't look fully merged into ${defaultBranch} yet.`) 79 | } 80 | const sure = 81 | yes || 82 | (await confirm({ 83 | message: `Are you sure you want to delete '${currentBranch}'?`, 84 | default: true, 85 | })) 86 | if (sure) { 87 | await git.deleteLocalBranch(currentBranch, true) 88 | success(`Deleted branch '${currentBranch}'`) 89 | } else { 90 | warn(`Did not delete branch '${currentBranch}'`) 91 | } 92 | } else { 93 | throw error 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/branch-utils.ts: -------------------------------------------------------------------------------- 1 | import { relative } from "node:path" 2 | 3 | import type { SimpleGit } from "simple-git" 4 | import { error, warn } from "./logger" 5 | 6 | export async function getDefaultBranch(git: SimpleGit) { 7 | try { 8 | const result = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]) 9 | if (result) { 10 | const defaultBranch = result.trim().replace("refs/remotes/origin/", "") 11 | if (defaultBranch) { 12 | return defaultBranch 13 | } 14 | } 15 | 16 | const branches = await git.branch(["-r"]) 17 | const defaultBranch = branches.all 18 | .find((branch) => branch.includes("origin/HEAD -> origin/")) 19 | ?.split("origin/HEAD -> origin/")[1] 20 | 21 | return defaultBranch || "main" 22 | } catch (err) { 23 | // This can happen if you've never pushed to a remote before 24 | if ( 25 | err instanceof Error && 26 | err.message.includes("ref refs/remotes/origin/HEAD is not a symbolic ref") 27 | ) { 28 | const result = await git.raw(["config", "--get", "init.defaultBranch"]) 29 | if (result?.trim()) { 30 | return result.trim() 31 | } 32 | 33 | warn( 34 | "The command `git config --get init.defaultBranch` failed. Try it manually.", 35 | ) 36 | warn( 37 | "You might need to run `git config --global init.defaultBranch main` manually.", 38 | ) 39 | error( 40 | "Unable to determine the default branch by checking the origin/HEAD", 41 | ) 42 | } 43 | throw err 44 | } 45 | } 46 | 47 | export async function getCurrentBranch(git: SimpleGit) { 48 | const branchSummary = await git.branch(["--show-current"]) 49 | const currentBranch = branchSummary.current 50 | if (currentBranch) { 51 | return currentBranch 52 | } 53 | const rawBranch = await git.raw("branch", "--show-current") 54 | return rawBranch.trim() 55 | } 56 | 57 | export async function countCommitsAhead( 58 | git: SimpleGit, 59 | branchName: string, 60 | upstreamName: string, 61 | ) { 62 | const result = await git.raw([ 63 | "rev-list", 64 | "--count", 65 | `${upstreamName}/${branchName}..${branchName}`, 66 | ]) 67 | const count = parseInt(result.trim(), 10) 68 | return count 69 | } 70 | 71 | export async function countCommitsBehind( 72 | git: SimpleGit, 73 | branchName: string, 74 | upstreamName: string, 75 | ) { 76 | const result = await git.raw([ 77 | "rev-list", 78 | "--count", 79 | `${branchName}..${upstreamName}/${branchName}`, 80 | ]) 81 | const count = parseInt(result.trim(), 10) 82 | return count 83 | } 84 | 85 | export async function getUntrackedFiles(git: SimpleGit): Promise { 86 | const relativeToRepo = (await git.revparse(["--show-prefix"])) || "." 87 | const status = await git.status() 88 | return status.not_added 89 | .filter((file) => !file.endsWith("~")) 90 | .map((file) => relative(relativeToRepo, file)) 91 | } 92 | 93 | export async function getUnstagedFiles(git: SimpleGit): Promise { 94 | const relativeToRepo = (await git.revparse(["--show-prefix"])) || "." 95 | const status = await git.status() 96 | return status.modified.map((file) => relative(relativeToRepo, file)) 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gg2 2 | 3 | CLI tool for creating, committing, and generally managing your git branches. 4 | 5 | ## Installation 6 | 7 | ### Homebrew 8 | 9 | ```bash 10 | brew tap peterbe/gg2 11 | brew install gg2 12 | ``` 13 | 14 | To set up tab-completion, add this to your `~/.bashrc` or `~/.zshrc` file: 15 | 16 | ```sh 17 | source <(gg2 shell-completion) 18 | ``` 19 | 20 | Now, you should be able to type `gg2 [TAB]` and see all options. 21 | 22 | You can also download the release binaries here: 23 | 24 | ### From source 25 | 26 | ```sh 27 | git clone https://github.com/peterbe/gg2.git && cd gg2 28 | bun install 29 | bun run build 30 | cp out/gg ~/bin 31 | chmod +x ~/bin/gg 32 | ``` 33 | 34 | ## Compiled 35 | 36 | The source code for this CLI is written in TypeScript. 37 | 38 | The code is **compiled** using [`bun`](https://bun.com/docs/bundler/executables) to 39 | an executable that is compiled specifically for your operating system, for example, 40 | `bun-darwin-arm64`. Because it's compiled to **a single executable binary**, the 41 | start-up time to run the CLI is **very fast**. ⚡ 42 | 43 | For example, the GitHub CLI, `gh`, is written in Go and is also compiled to a single 44 | executable binary. For comparison, running the [`hyperfine` command-line 45 | benchmarking tool](https://github.com/sharkdp/hyperfine) using: 46 | 47 | ```bash 48 | hyperfine "gg --version" "gh --version" 49 | ``` 50 | 51 | Results: 52 | 53 | ```text 54 | Benchmark 1: gg --version 55 | Time (mean ± σ): 25.0 ms ± 0.7 ms [User: 21.2 ms, System: 9.4 ms] 56 | Range (min … max): 23.6 ms … 28.5 ms 108 runs 57 | 58 | Benchmark 2: gh --version 59 | Time (mean ± σ): 29.3 ms ± 0.6 ms [User: 26.9 ms, System: 8.0 ms] 60 | Range (min … max): 27.9 ms … 31.1 ms 90 runs 61 | 62 | Summary 63 | gg --version ran 64 | 1.17 ± 0.04 times faster than gh --version 65 | ``` 66 | 67 | The point is; it takes on average **25 milliseconds** to run the `gg --version` command. 68 | 69 | ## Development 70 | 71 | ```bash 72 | git clone https://github.com/peterbe/gg2.git 73 | cd gg2 74 | bun install 75 | bun run build 76 | ``` 77 | 78 | This will create an executable called `out/gg`. 79 | You can test it with: 80 | 81 | ```bash 82 | ./out/gg --help 83 | ``` 84 | 85 | But if you don't want to compile every time, you can simply type, for example: 86 | 87 | ```bash 88 | bun run src/index.ts --help 89 | ``` 90 | 91 | If you prefer to test with the compiled executable, you can run: 92 | 93 | ```bash 94 | bun build --watch src/index.ts --target=bun --outfile ~/bin/gg --compile 95 | ``` 96 | 97 | which will constantly compile an executable and it into your `~/bin` directory. 98 | 99 | ### Test and linting 100 | 101 | To run the unit tests: 102 | 103 | ```bash 104 | bun test 105 | ``` 106 | 107 | To *format* and check linting, run: 108 | 109 | ```bash 110 | bun run lint 111 | ``` 112 | 113 | If you want to check the linting without formatting, run: 114 | 115 | ```bash 116 | bun run lint:check 117 | ``` 118 | 119 | ## About Homebrew 120 | 121 | The code that makes it possible to install with Homebrew is here: 122 | 123 | -------------------------------------------------------------------------------- /src/shell-completion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * As a tip for development/debugging, to test this outside of bash/zsh 3 | * you can simulate what the bash/zsh script does by using this notation, 4 | * for example: 5 | * 6 | * gg shell-completion --list "gg\ncommit\n" 7 | * 8 | */ 9 | 10 | import type { Command } from "commander" 11 | 12 | // @ts-expect-error - a trick to make a non TS file part of memory and build 13 | import code from "./completion.sh" with { type: "text" } 14 | 15 | export async function shellCompletion() { 16 | console.log(code) 17 | } 18 | 19 | type Options = { 20 | search?: string 21 | program: Command 22 | } 23 | 24 | export async function printCompletions(options: Options) { 25 | const { program } = options 26 | // This search, if not undefined, will be a multi-line string always 27 | // starts with the word "gg". For example, if the user has typed 28 | // `gg [TAB]` what's sent here is that search === "gg\n". 29 | const words = (options.search || "").split(/\s+/) 30 | 31 | words.shift() 32 | 33 | if (words.length === 1) { 34 | const word = words[0] 35 | const names: string[] = [] 36 | for (const command of program.commands) { 37 | if (command.name() === "shell-completion") continue 38 | names.push(command.name()) 39 | for (const alias of command.aliases()) { 40 | names.push(alias) 41 | } 42 | } 43 | names.sort((a, b) => a.localeCompare(b)) 44 | console.log( 45 | names 46 | .filter((name) => { 47 | if (word) return name.startsWith(word) 48 | return true 49 | }) 50 | .join("\n"), 51 | ) 52 | } else if (words.length === 2) { 53 | // E.g. "start -" 54 | const word = words[0] 55 | for (const command of program.commands) { 56 | if (command.name() === "shell-completion") { 57 | continue 58 | } 59 | if (word === command.name()) { 60 | const flags: string[] = [] 61 | for (const option of command.arguments(word).options) { 62 | if (option.long) flags.push(option.long) 63 | if (option.short) flags.push(option.short) 64 | } 65 | 66 | const commands = command.commands.map((command) => command.name()) 67 | 68 | console.log( 69 | [...commands, ...flags] 70 | .filter((flag) => { 71 | if (words[1]) { 72 | return flag.startsWith(words[1]) 73 | } 74 | return true 75 | }) 76 | .join("\n"), 77 | ) 78 | } 79 | } 80 | } else if (words.length === 3) { 81 | // E.g. "gg github pr " 82 | const word = words[0] 83 | const subword = words[1] 84 | for (const command of program.commands) { 85 | if (word === command.name()) { 86 | for (const subcommand of command.commands) { 87 | if (subcommand.name() === subword) { 88 | const flags: string[] = [] 89 | for (const option of subcommand.arguments(word).options) { 90 | if (option.long) flags.push(option.long) 91 | if (option.short) flags.push(option.short) 92 | } 93 | 94 | const commands = subcommand.commands.map((command) => 95 | command.name(), 96 | ) 97 | console.log( 98 | [...commands, ...flags] 99 | .filter((flag) => { 100 | if (words[2]) { 101 | return flag.startsWith(words[2]) 102 | } 103 | return true 104 | }) 105 | .join("\n"), 106 | ) 107 | } 108 | } 109 | } 110 | } 111 | } else { 112 | // Currently not supported. 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/end-to-end.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, test } from "bun:test" 2 | import { mkdtemp, rm } from "node:fs/promises" 3 | import { tmpdir, userInfo } from "node:os" 4 | import { join } from "node:path" 5 | import { $ } from "bun" 6 | 7 | describe("basics", async () => { 8 | let tempDir = "" 9 | let tempConfigFile = "" 10 | const original_GG_CONFIG_FILE = process.env.GG_CONFIG_FILE 11 | 12 | beforeEach(async () => { 13 | tempDir = await mkdtemp(join(tmpdir(), "gg-test")) 14 | tempConfigFile = join(join(tempDir, ".."), ".gg.json") 15 | process.env.GG_CONFIG_FILE = tempConfigFile 16 | }) 17 | afterEach(async () => { 18 | await rm(tempDir, { recursive: true }) 19 | 20 | process.env.GG_CONFIG_FILE = original_GG_CONFIG_FILE 21 | }) 22 | 23 | test("version", async () => { 24 | const output = await $`gg --version`.text() 25 | expect(/\d+\.\d+\d+/.test(output)).toBe(true) 26 | }) 27 | test("help", async () => { 28 | const output = await $`gg --help`.text() 29 | expect(/Usage:/.test(output)).toBe(true) 30 | }) 31 | 32 | test("create branch and commit", async () => { 33 | await $`git init --initial-branch=main`.cwd(tempDir) 34 | 35 | // Don't know why this is necessary for the sake of GitHub Actions Linux runners 36 | await $`git config --global init.defaultBranch main`.cwd(tempDir) 37 | 38 | await $`git config --global user.email "you@example.com"`.cwd(tempDir) 39 | await $`git config --global user.name "Your Name"`.cwd(tempDir) 40 | await $`echo "# My Project" > README.md`.cwd(tempDir) 41 | await $`git add README.md`.cwd(tempDir) 42 | 43 | await $`echo "This is my new branch" | gg start`.cwd(tempDir) 44 | const branchName = await $`git branch --show-current`.cwd(tempDir).text() 45 | expect(branchName.trim()).toBe("this-is-my-new-branch") 46 | 47 | const config = JSON.parse(await Bun.file(tempConfigFile).text()) 48 | const repos = config.REPOS 49 | // temp dirs are funny. It's /private/var/... on macOS 50 | // but /var/folders/k5/48d8b8wx5t359cw50_rhd6r00000gp/T/... when created with mkdtemp 51 | const repoKey = Object.keys(repos).find((key) => key.endsWith(tempDir)) 52 | expect(repoKey).toBeDefined() 53 | const repoConfig = config.REPOS[repoKey as string] 54 | expect(repoConfig).toBeDefined() 55 | expect(repoConfig.BRANCH_TITLES["this-is-my-new-branch"]).toBe( 56 | "This is my new branch", 57 | ) 58 | 59 | // Make some edits 60 | await $`echo "Some changes" >> README.md`.cwd(tempDir) 61 | await $`git add README.md`.cwd(tempDir) 62 | 63 | await $`echo "Yes that's the title" | gg commit`.cwd(tempDir) 64 | 65 | const log = await $`git log`.cwd(tempDir).text() 66 | expect(log.includes("Yes that's the title")).toBe(true) 67 | }) 68 | 69 | test("configure and config", async () => { 70 | await $`git init --initial-branch=main`.cwd(tempDir) 71 | const config = await $`gg config`.cwd(tempDir).text() 72 | expect(config.includes("No configuration found")).toBe(true) 73 | 74 | const defaultBrancPrefix = `${userInfo().username}/` 75 | Bun.env.TEST_CONFIGURE_ANSWER = "branch-prefix" 76 | try { 77 | const o = await $`printf "\n" | gg configure`.cwd(tempDir).text() 78 | expect(o.includes("Old value: nothing set")).toBe(true) 79 | expect(o.includes(`New value: ${defaultBrancPrefix}`)).toBe(true) 80 | } finally { 81 | delete Bun.env.TEST_CONFIGURE_ANSWER 82 | } 83 | const configAfter = JSON.parse(await Bun.file(tempConfigFile).text()) 84 | const repos = configAfter.REPOS 85 | const repoKey = Object.keys(repos).find((key) => key.endsWith(tempDir)) 86 | const repoConfig = configAfter.REPOS[repoKey as string] 87 | expect(repoConfig).toBeDefined() 88 | expect(repoConfig.CONFIG["branch-prefix"]).toBe(defaultBrancPrefix) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from "node:os" 2 | import { sep } from "node:path" 3 | 4 | import simpleGit from "simple-git" 5 | import { warn } from "./logger" 6 | 7 | const configFilePath = process.env.GG_CONFIG_FILE || "~/.gg.json" 8 | 9 | const db = Bun.file(expandPath(configFilePath)) 10 | 11 | export async function storeNewBranch( 12 | branchName: string, 13 | info: { 14 | title: string 15 | baseBranch: string 16 | }, 17 | ): Promise { 18 | const [data, repoData] = await getRepoData() 19 | repoData.BRANCH_TITLES[branchName] = info.title 20 | if (!repoData.BASE_BRANCHES) { 21 | repoData.BASE_BRANCHES = {} 22 | } 23 | repoData.BASE_BRANCHES[branchName] = info.baseBranch 24 | await db.write(JSON.stringify(data, null, 2)) 25 | } 26 | 27 | type ConfigKeys = 28 | | "branch-prefix" 29 | | "title-prefix" 30 | | "upstream-name" 31 | | "offer-auto-merge" 32 | | "disable-pr-creation" 33 | 34 | type GlobalConfigKeys = "github-token" // more to come 35 | 36 | type BranchTitles = Record 37 | type ConfigValues = Record 38 | 39 | type OriginalBranches = Record 40 | 41 | type RepoData = { 42 | BRANCH_TITLES: BranchTitles 43 | CONFIG: ConfigValues 44 | BASE_BRANCHES?: OriginalBranches // optional, may not exist in older files 45 | } 46 | type StorageObject = { 47 | REPOS: { 48 | [repo: string]: RepoData 49 | } 50 | GLOBAL_CONFIG: ConfigValues 51 | } 52 | 53 | async function getData(): Promise { 54 | const defaultData: StorageObject = { 55 | REPOS: {}, 56 | GLOBAL_CONFIG: {}, 57 | } 58 | const data = (await db.exists()) 59 | ? (JSON.parse(await db.text()) as StorageObject) 60 | : defaultData 61 | 62 | return data 63 | } 64 | 65 | async function getRepoKey() { 66 | const git = simpleGit() 67 | if (!(await git.checkIsRepo())) { 68 | throw new Error("Not a git repository") 69 | } 70 | return await git.revparse(["--show-toplevel"]) 71 | } 72 | 73 | export async function getTitle( 74 | branchName: string, 75 | ): Promise { 76 | const [, repoData] = await getRepoData() 77 | return repoData.BRANCH_TITLES[branchName] 78 | } 79 | 80 | export async function getBaseBranch( 81 | branchName: string, 82 | ): Promise { 83 | const [, repoData] = await getRepoData() 84 | // BASE_BRANCHES may not exist in older files. 85 | return repoData.BASE_BRANCHES ? repoData.BASE_BRANCHES[branchName] : undefined 86 | } 87 | 88 | function expandPath(path: string): string { 89 | const split = path.split(sep) 90 | return split 91 | .map((part) => { 92 | if (part === "~") { 93 | return homedir() 94 | } 95 | return part 96 | }) 97 | .join(sep) 98 | } 99 | 100 | export async function storeConfig(key: ConfigKeys, value: string | boolean) { 101 | const [data, repoData] = await getRepoData() 102 | repoData.CONFIG[key] = value 103 | await db.write(JSON.stringify(data, null, 2)) 104 | } 105 | 106 | export async function getRepoConfig(): Promise { 107 | const [, repoData] = await getRepoData() 108 | return repoData.CONFIG 109 | } 110 | 111 | async function getRepoData(): Promise<[StorageObject, RepoData]> { 112 | const data = await getData() 113 | const repoKey = await getRepoKey() 114 | if (!data.REPOS[repoKey]) { 115 | data.REPOS[repoKey] = { 116 | BRANCH_TITLES: {}, 117 | CONFIG: {}, 118 | BASE_BRANCHES: {}, 119 | } 120 | } 121 | return [data, data.REPOS[repoKey]] 122 | } 123 | 124 | export async function getGlobalConfig(): Promise { 125 | const data = await getData() 126 | return data.GLOBAL_CONFIG 127 | } 128 | 129 | export async function storeGlobalConfig(key: GlobalConfigKeys, value: string) { 130 | const data = await getData() 131 | data.GLOBAL_CONFIG[key] = value 132 | await db.write(JSON.stringify(data, null, 2)) 133 | } 134 | 135 | export async function getUpstreamName(): Promise { 136 | const config = await getRepoConfig() 137 | 138 | let upstreamName = config["upstream-name"] 139 | if (!upstreamName) { 140 | warn( 141 | "No upstream name configured, defaulting to 'origin' (run 'gg configure' to set it)", 142 | ) 143 | upstreamName = "origin" 144 | } 145 | 146 | return upstreamName as string 147 | } 148 | -------------------------------------------------------------------------------- /src/find-branches.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from "@inquirer/prompts" 2 | import fuzzysort from "fuzzysort" 3 | import kleur from "kleur" 4 | import simpleGit, { type BranchSummaryBranch } from "simple-git" 5 | import { getHumanAge } from "./human-age" 6 | import { success, warn } from "./logger" 7 | 8 | type Options = { 9 | search?: string 10 | number: string 11 | cleanup?: boolean 12 | reverse?: boolean 13 | cleanupAll?: boolean 14 | yes?: boolean 15 | } 16 | export async function findBranches(search: string, options: Options) { 17 | const yes = Boolean(options.yes) 18 | const cleanup = Boolean(options.cleanup) 19 | const cleanupAll = Boolean(options.cleanupAll) 20 | const number = Number.parseInt(options.number, 10) 21 | const reverse = Boolean(options.reverse) 22 | if (Number.isNaN(number)) { 23 | throw new Error("Not a number") 24 | } 25 | 26 | const git = simpleGit() 27 | const currentBranchSummary = await git.branch() 28 | const currentBranch = currentBranchSummary.current 29 | 30 | const rawDates = await git.raw( 31 | "branch", 32 | "--all", 33 | "--format=%(refname:short)|%(committerdate:iso)", 34 | ) 35 | const dates = new Map() 36 | for (const line of rawDates.split(/\n+/g)) { 37 | const [refname, dateStr] = line.split("|") 38 | if (refname && dateStr) { 39 | dates.set(refname, new Date(dateStr)) 40 | } 41 | } 42 | 43 | const isMerged = new Set() 44 | const rawMerged = await git.raw("branch", "--all", "--merged") 45 | for (const line of rawMerged.split(/\n+/g)) { 46 | if (line.trim()) { 47 | isMerged.add(line.trim()) 48 | } 49 | } 50 | 51 | const branchSummary = await git.branch([ 52 | "--all", 53 | reverse ? "--sort=committerdate" : "--sort=-committerdate", 54 | ]) 55 | 56 | type SearchResult = { 57 | name: string 58 | highlit?: string 59 | branchInfo?: BranchSummaryBranch 60 | merged: boolean 61 | } 62 | 63 | async function printSearchResults(searchResults: SearchResult[]) { 64 | for (const { name, highlit, branchInfo, merged } of searchResults) { 65 | const date = dates.get(name) 66 | console.log( 67 | `${date ? kleur.dim(`${getHumanAge(date)} ago`) : kleur.italic("no date")}`.padEnd( 68 | 30, 69 | " ", 70 | ), 71 | highlit || name, 72 | branchInfo?.current 73 | ? kleur.bold().green(" (Your current branch)") 74 | : merged 75 | ? kleur.cyan(" (merged already)") 76 | : "", 77 | ) 78 | 79 | if (cleanup) { 80 | const doDelete = await confirm({ 81 | message: `Delete this branch locally?`, 82 | default: false, 83 | }) 84 | if (doDelete) { 85 | await git.deleteLocalBranch(name) 86 | success(`Deleted branch ${kleur.bold(name)}\n`) 87 | } 88 | } 89 | } 90 | if (cleanupAll) { 91 | const doDelete = await confirm({ 92 | message: `Delete all branches locally?`, 93 | default: false, 94 | }) 95 | if (doDelete) { 96 | for (const { name } of searchResults) { 97 | await git.deleteLocalBranch(name) 98 | success(`Deleted branch ${kleur.bold(name)}\n`) 99 | } 100 | } 101 | } 102 | } 103 | 104 | const branchNames = branchSummary.all.filter( 105 | (name) => !name.startsWith("remotes/origin/"), 106 | ) 107 | let countFound = 0 108 | const foundBranchNames = branchNames 109 | const searchResults: SearchResult[] = [] 110 | for (const branch of foundBranchNames) { 111 | const branchInfo = branchSummary.branches[branch] 112 | 113 | const merged = isMerged.has(branch) 114 | 115 | if ((cleanup || cleanupAll) && !merged) { 116 | continue 117 | } 118 | 119 | if (search) { 120 | const matched = fuzzysort.single(search, branch) 121 | if (!matched) { 122 | continue 123 | } 124 | 125 | // Bold 126 | searchResults.push({ 127 | name: branch, 128 | highlit: matched.highlight("\x1b[1m", "\x1b[0m"), 129 | branchInfo, 130 | merged, 131 | }) 132 | } else { 133 | searchResults.push({ 134 | name: branch, 135 | branchInfo, 136 | merged, 137 | }) 138 | } 139 | countFound++ 140 | } 141 | if (!countFound) { 142 | warn("Found nothing") 143 | } else { 144 | await printSearchResults(searchResults) 145 | } 146 | 147 | if ( 148 | !(cleanup || cleanupAll) && 149 | countFound === 1 && 150 | searchResults[0] && 151 | searchResults[0].name !== currentBranch 152 | ) { 153 | const found = searchResults[0] 154 | if (found) { 155 | const checkOut = 156 | yes || 157 | (await confirm({ 158 | message: `Check out (${found.name}):`, 159 | default: true, 160 | })) 161 | const status = await git.status() 162 | if (!status.isClean()) { 163 | throw new Error( 164 | "Current branch is not in a clean state. Run `git status`", 165 | ) 166 | } 167 | 168 | if (checkOut) { 169 | await git.checkout(found.name) 170 | success(`Checked out branch ${kleur.bold(found.name)}`) 171 | } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/branch-info.ts: -------------------------------------------------------------------------------- 1 | import kleur from "kleur" 2 | import simpleGit from "simple-git" 3 | import { 4 | countCommitsAhead, 5 | countCommitsBehind, 6 | getCurrentBranch, 7 | getDefaultBranch, 8 | } from "./branch-utils" 9 | import { 10 | findBranchByBranchName, 11 | findPRByBranchName, 12 | getGitHubNWO, 13 | } from "./github-utils" 14 | import { warn } from "./logger" 15 | import { getBaseBranch, getUpstreamName } from "./storage" 16 | 17 | export async function branchInfo() { 18 | const git = simpleGit() 19 | const currentBranch = await getCurrentBranch(git) 20 | const defaultBranch = await getDefaultBranch(git) 21 | if (defaultBranch === currentBranch) { 22 | warn(`You are on the default branch (${defaultBranch}) already.`) 23 | return 24 | } 25 | 26 | const records: Record = {} 27 | const warnings: string[] = [] 28 | 29 | const status = await git.status() 30 | if (!status.isClean()) { 31 | const files = status.files.length 32 | records.Status = kleur.bold( 33 | kleur.yellow( 34 | `Uncommitted changes (${files} file${files === 1 ? "" : "s"})`, 35 | ), 36 | ) 37 | warnings.push("Local branch has uncommitted changes.") 38 | } 39 | records["Current Branch"] = kleur.bold(currentBranch) 40 | records["Default Branch"] = kleur.bold(defaultBranch) 41 | 42 | const storedBaseBranch = await getBaseBranch(currentBranch) 43 | records["Base Branch"] = storedBaseBranch 44 | ? kleur.bold(storedBaseBranch) 45 | : kleur.dim("(not set)") 46 | 47 | const upstreamName = await getUpstreamName() 48 | const remotes = await git.getRemotes(true) // true includes URLs 49 | const origin = remotes.find((remote) => remote.name === upstreamName) 50 | const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 51 | const nwo = originUrl && getGitHubNWO(originUrl) 52 | if (nwo) { 53 | records["GitHub Repo"] = nwo ? kleur.bold(nwo) : null 54 | const pr = await findPRByBranchName(currentBranch) 55 | records["GitHub PR"] = pr ? kleur.bold(pr.html_url) : null 56 | 57 | const remoteBranch = await findBranchByBranchName(currentBranch) 58 | records["GitHub Branch"] = remoteBranch 59 | ? kleur.bold(remoteBranch._links.html) 60 | : null 61 | if (remoteBranch) { 62 | const commitsAhead = await countCommitsAhead( 63 | git, 64 | currentBranch, 65 | upstreamName, 66 | ) 67 | records["Commits Ahead"] = 68 | `${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} ahead ${upstreamName}/${currentBranch}` 69 | if (commitsAhead > 0) { 70 | records["Commits Ahead"] = kleur.yellow(records["Commits Ahead"]) 71 | } 72 | 73 | const commitsBehind = await countCommitsBehind( 74 | git, 75 | currentBranch, 76 | upstreamName, 77 | ) 78 | records["Commits Behind"] = 79 | `${commitsBehind} commit${commitsBehind === 1 ? "" : "s"} behind ${upstreamName}/${currentBranch}` 80 | if (commitsBehind > 0) { 81 | records["Commits Behind"] = kleur.bold( 82 | kleur.yellow(records["Commits Behind"]), 83 | ) 84 | } 85 | 86 | if (commitsBehind > 0) { 87 | warnings.push( 88 | "You might want to pull the latest changes from the remote branch.", 89 | ) 90 | } else if (commitsAhead > 0) { 91 | warnings.push( 92 | "You might want to push your commits to the remote branch.", 93 | ) 94 | } 95 | } 96 | } 97 | 98 | const longestKeyLength = Math.max( 99 | ...Object.keys(records).map((key) => key.length), 100 | ) 101 | const extraPadding = 2 102 | 103 | console.log("Branch Information:\n") 104 | for (const [key, value] of Object.entries(records)) { 105 | const paddedKey = `${key}:`.padEnd(longestKeyLength + extraPadding, " ") 106 | console.log(` ${paddedKey} ${value === null ? kleur.dim("n/a") : value}`) 107 | } 108 | console.log("") 109 | 110 | for (const warning of warnings) { 111 | warn(warning) 112 | } 113 | 114 | // const status = await git.status() 115 | // if (!status.isClean()) { 116 | // throw new Error("Current branch is not in a clean state. Run `git status`") 117 | // } 118 | 119 | // const status = await git.status() 120 | // if (!status.isClean()) { 121 | // throw new Error("Current branch is not in a clean state. Run `git status`") 122 | // } 123 | 124 | // const upstreamName = await getUpstreamName() 125 | 126 | // const remotes = await git.getRemotes(true) // true includes URLs 127 | // const origin = remotes.find((remote) => remote.name === upstreamName) 128 | // // const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 129 | // if (!origin?.name) { 130 | // throw new Error(`Could not find a remote called '${upstreamName}'`) 131 | // } 132 | // const originName = origin.name 133 | 134 | // await git.fetch(originName, defaultBranch) 135 | 136 | // await git.mergeFromTo(originName, defaultBranch) 137 | 138 | // success( 139 | // `Latest ${originName}/${defaultBranch} branch merged into this branch.`, 140 | // ) 141 | 142 | // let pushToRemote = false 143 | // if (!pushToRemote && origin) { 144 | // pushToRemote = await confirm({ 145 | // message: `Push to ${originName}:`, 146 | // default: false, 147 | // }) 148 | // } 149 | 150 | // if (pushToRemote) { 151 | // await git.push(upstreamName, currentBranch) 152 | // success(`Changes pushed to ${originName}/${currentBranch}`) 153 | // } 154 | } 155 | -------------------------------------------------------------------------------- /src/configure-repo.ts: -------------------------------------------------------------------------------- 1 | // import { appendFileSync } from "node:fs" 2 | import { userInfo } from "node:os" 3 | import { basename } from "node:path" 4 | import { confirm, input, select } from "@inquirer/prompts" 5 | import kleur from "kleur" 6 | import simpleGit from "simple-git" 7 | import { warn } from "./logger" 8 | import { getRepoConfig, storeConfig } from "./storage" 9 | 10 | // const dbg = (s: string) => { 11 | // appendFileSync("/tmp/dbg.log", s) 12 | // appendFileSync("/tmp/dbg.log", "\n") 13 | // } 14 | 15 | // This exists just to make it possible to bypass the interactive prompt 16 | // in end-to-end tests. 17 | async function selectWrappedForTesting( 18 | config: Parameters[0], 19 | ): Promise> { 20 | // for (const [key, value] of Object.entries(Bun.env)) { 21 | // dbg(`${key}=${value}`) 22 | // } 23 | if (Bun.env.NODE_ENV === "test" && Bun.env.TEST_CONFIGURE_ANSWER) { 24 | return Bun.env.TEST_CONFIGURE_ANSWER 25 | } 26 | return select(config) 27 | } 28 | 29 | export async function configureRepo() { 30 | const git = simpleGit() 31 | const rootPath = await git.revparse(["--show-toplevel"]) 32 | const repoName = basename(rootPath) 33 | 34 | const answer = await selectWrappedForTesting({ 35 | message: `What do you want to configure (for repo: ${repoName})`, 36 | choices: [ 37 | { 38 | name: "Common branch prefix", 39 | value: "branch-prefix", 40 | }, 41 | { 42 | name: "Common title prefix", 43 | value: "title-prefix", 44 | }, 45 | { 46 | name: "Upstream name", 47 | value: "upstream-name", 48 | }, 49 | { 50 | name: "Auto-merge PRs", 51 | value: "auto-merge", 52 | }, 53 | { 54 | name: "Disable PR creation", 55 | value: "disable-pr-creation", 56 | }, 57 | 58 | // { 59 | // name: "yarn", 60 | // value: "yarn", 61 | // description: "yarn is an awesome package manager", 62 | // }, 63 | // new Separator(), 64 | // { 65 | // name: "jspm", 66 | // value: "jspm", 67 | // disabled: true, 68 | // }, 69 | // { 70 | // name: "pnpm", 71 | // value: "pnpm", 72 | // disabled: "(pnpm is not available)", 73 | // }, 74 | ], 75 | }) 76 | if (answer === "branch-prefix") { 77 | await configureBranchPrefix() 78 | } else if (answer === "title-prefix") { 79 | await configureTitlePrefix() 80 | } else if (answer === "upstream-name") { 81 | await configureUpstreamName() 82 | } else if (answer === "auto-merge") { 83 | await configureAutoMerge() 84 | } else if (answer === "disable-pr-creation") { 85 | await configureDisablePRCreation() 86 | } else { 87 | warn("No selected thing to configure. Bye") 88 | } 89 | } 90 | 91 | async function configureBranchPrefix() { 92 | const config = await getRepoConfig() 93 | const defaultPrefix = 94 | (config["branch-prefix"] as string) || `${userInfo().username}/` 95 | const value = await input({ 96 | message: `Prefix:`, 97 | default: defaultPrefix, 98 | prefill: "tab", 99 | }) 100 | await storeConfig("branch-prefix", value) 101 | console.log( 102 | `Old value: ${config["branch-prefix"] ? kleur.yellow(config["branch-prefix"] as string) : kleur.italic("nothing set")}`, 103 | ) 104 | console.log( 105 | `New value: ${value ? kleur.green(value) : kleur.italic("empty")}`, 106 | ) 107 | } 108 | 109 | async function configureTitlePrefix() { 110 | const config = await getRepoConfig() 111 | const defaultPrefix = 112 | (config["title-prefix"] as string) || `${userInfo().username}/` 113 | const value = await input({ 114 | message: `Prefix:`, 115 | default: defaultPrefix, 116 | prefill: "tab", 117 | }) 118 | await storeConfig("title-prefix", value) 119 | console.log( 120 | `Old value: ${config["title-prefix"] ? kleur.yellow(config["title-prefix"] as string) : kleur.italic("nothing set")}`, 121 | ) 122 | console.log( 123 | `New value: ${value ? kleur.green(value) : kleur.italic("empty")}`, 124 | ) 125 | } 126 | 127 | async function configureUpstreamName() { 128 | const KEY = "upstream-name" 129 | const config = await getRepoConfig() 130 | const defaultName = (config[KEY] as string) || "origin" 131 | const value = await input({ 132 | message: `Name:`, 133 | default: defaultName, 134 | prefill: "tab", 135 | }) 136 | await storeConfig(KEY, value) 137 | console.log( 138 | `Old value: ${config[KEY] ? kleur.yellow(config[KEY] as string) : kleur.italic("nothing set")}`, 139 | ) 140 | console.log( 141 | `New value: ${value ? kleur.green(value) : kleur.italic("empty")}`, 142 | ) 143 | } 144 | 145 | async function configureAutoMerge() { 146 | const KEY = "offer-auto-merge" 147 | const config = await getRepoConfig() 148 | const value = await confirm({ 149 | message: `Suggest Auto-merge on PRs:`, 150 | default: config[KEY] === undefined ? true : Boolean(config[KEY]), 151 | }) 152 | await storeConfig(KEY, value) 153 | if (value !== config[KEY]) { 154 | console.log( 155 | `Old value: ${config[KEY] === undefined ? kleur.italic("not set") : kleur.bold(JSON.stringify(config[KEY]))}`, 156 | ) 157 | console.log( 158 | `New value: ${value ? kleur.green("true") : kleur.red("false")}`, 159 | ) 160 | } 161 | } 162 | 163 | async function configureDisablePRCreation() { 164 | const KEY = "disable-pr-creation" 165 | const config = await getRepoConfig() 166 | const value = await confirm({ 167 | message: `Disable PR creation after commit:`, 168 | default: config[KEY] === undefined ? true : Boolean(config[KEY]), 169 | }) 170 | await storeConfig(KEY, value) 171 | if (value !== config[KEY]) { 172 | console.log( 173 | `Old value: ${config[KEY] === undefined ? kleur.italic("not set") : kleur.bold(JSON.stringify(config[KEY]))}`, 174 | ) 175 | console.log( 176 | `New value: ${value ? kleur.green("true") : kleur.red("false")}`, 177 | ) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander" 2 | 3 | import { version } from "../package.json" 4 | import { branchInfo } from "./branch-info" 5 | import { commitBranch } from "./commit-branch" 6 | import { configureRepo } from "./configure-repo" 7 | import { createPR } from "./create-pr" 8 | import { findBranches } from "./find-branches" 9 | import { getBack } from "./get-back" 10 | import { gitHubPR, gitHubToken } from "./github" 11 | import { error } from "./logger" 12 | import { mainMerge } from "./main-merge" 13 | import { originPush } from "./origin-push" 14 | import { parentBranch } from "./parent-branch" 15 | import { repoConfig } from "./repo-config" 16 | import { printCompletions, shellCompletion } from "./shell-completion" 17 | import { startBranch } from "./start-branch" 18 | 19 | const program = new Command() 20 | 21 | program 22 | .name("gg") 23 | .description("CLI to make it easier to manage git branches") 24 | .version(version) 25 | 26 | program 27 | .command("shell-completion") 28 | .argument("[search]", "Search input") 29 | .option("--list", "List all options") 30 | .description( 31 | "Prints the Bash and Zsh completion code that your shell can eval", 32 | ) 33 | .action((search, options) => { 34 | if (options.list) { 35 | wrap(printCompletions({ search, program }), options.debug) 36 | } else { 37 | wrap(shellCompletion(), options.debug) 38 | } 39 | }) 40 | 41 | program 42 | .command("start") 43 | .description("Create a new branch") 44 | .option("--debug", "Debug mode (shows traceback)") 45 | .argument( 46 | "[url-or-title...]", 47 | "GitHub or Jira ticket URL or just the title directly", 48 | ) 49 | .action((urlOrTitle, options) => { 50 | wrap(startBranch(urlOrTitle, options), options.debug) 51 | }) 52 | 53 | program 54 | .command("commit") 55 | .description("Commit and push changes") 56 | .option("--debug", "Debug mode (shows traceback)") 57 | .option("--no-verify", "No git hook verify") 58 | .option("-y, --yes", "Push") 59 | .argument("[message...]", "Commit message") // Note it's optional 60 | .action((message, options) => { 61 | wrap(commitBranch(message?.join(" ") || "", options), options.debug) 62 | }) 63 | 64 | program 65 | .command("configure") 66 | .description("Configure preferences for this repo") 67 | .option("--debug", "Debug mode (shows traceback)") 68 | // .option("-y, --yes", "Push") 69 | .action((options) => { 70 | wrap(configureRepo(), options.debug) 71 | }) 72 | 73 | program 74 | .command("config") 75 | .description("Prints the current repo config") 76 | .option("--debug", "Debug mode (shows traceback)") 77 | .action((options) => { 78 | wrap(repoConfig(), options.debug) 79 | }) 80 | 81 | program 82 | .command("getback") 83 | .description("Go back to the default branch and delete this one") 84 | .option("--debug", "Debug mode (shows traceback)") 85 | .option("-y, --yes", "Push") 86 | .action((options) => { 87 | wrap(getBack(options), options.debug) 88 | }) 89 | 90 | program 91 | .command("push") 92 | .description("Push the current branch to the remote") 93 | .option("--debug", "Debug mode (shows traceback)") 94 | .action((options) => { 95 | wrap(originPush(), options.debug) 96 | }) 97 | 98 | program // alias for `github pr` 99 | .command("pr") 100 | .description("Get the current GitHub Pull Request for the current branch") 101 | .option("--watch", "Keep checking the status till it changes") 102 | .action((options) => { 103 | wrap(gitHubPR(options), options.debug) 104 | }) 105 | 106 | program 107 | .command("mainmerge") 108 | .alias("mastermerge") 109 | .description( 110 | "Merge the origin_name/default_branch into the the current branch", 111 | ) 112 | .action((options) => { 113 | wrap(mainMerge(), options.debug) 114 | }) 115 | 116 | program 117 | .command("info") 118 | .description("Get information about the current branch") 119 | .option("--debug", "Debug mode (shows traceback)") 120 | .action((options) => { 121 | wrap(branchInfo(), options.debug) 122 | }) 123 | 124 | program 125 | .command("branches") 126 | .description("Find and check out a branch by name") 127 | .argument("[search]", "Search input (can be fuzzy)") 128 | .option("-n, --number ", "Max number of branches to show", "20") 129 | .option( 130 | "--cleanup", 131 | "Interactively ask about deleting found and *merged* branches", 132 | ) 133 | .option( 134 | "--cleanup-all", 135 | "Interactively ask about deleting *all* found and *merged* branches", 136 | ) 137 | .option("--reverse", "Reverse sort order") 138 | .option("-y, --yes", "Push") 139 | .action((search, options) => { 140 | wrap(findBranches(search, options), options.debug) 141 | }) 142 | 143 | const gitHubCommand = program 144 | .command("github") 145 | .description("Configure your connection to GitHub") 146 | 147 | gitHubCommand 148 | .command("token") 149 | .argument("[token]", "token") 150 | .description("Set your personal access token to GitHub") 151 | .option("--test", "Test if the existing token works") 152 | .option( 153 | "--test-prs", 154 | "Test if you can read pull requests in the current repo", 155 | ) 156 | .action((token, options) => { 157 | wrap(gitHubToken(token, options), options.debug) 158 | }) 159 | 160 | gitHubCommand 161 | .command("pr") 162 | .description("Get the current GitHub Pull Request for the current branch") 163 | .option("--watch", "Keep checking the status till it changes") 164 | .action((options) => { 165 | wrap(gitHubPR(options), options.debug) 166 | }) 167 | 168 | gitHubCommand 169 | .command("create") 170 | .description("Turn the current branch into a GitHub Pull Request") 171 | .option("-e, --enable-auto-merge", "Keep checking the status till it changes") 172 | .action((options) => { 173 | wrap(createPR(options), options.debug) 174 | }) 175 | 176 | program 177 | .command("parentbranch") 178 | .description("Print out the current branch's parent") 179 | .option("-y, --yes", "Push") 180 | .action((options) => { 181 | wrap(parentBranch(options), options.debug) 182 | }) 183 | 184 | program.parse() 185 | 186 | function wrap(promise: Promise, debug: boolean) { 187 | promise 188 | .then(() => { 189 | process.exit(0) 190 | }) 191 | .catch((err) => { 192 | if (err instanceof Error && err.name === "ExitPromptError") { 193 | // Ctrl-C 194 | process.exit(0) 195 | } 196 | 197 | if (debug) { 198 | throw err 199 | } 200 | error(err.message) 201 | process.exit(1) 202 | }) 203 | } 204 | -------------------------------------------------------------------------------- /src/github-utils.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "octokit" 2 | import simpleGit from "simple-git" 3 | import { getGlobalConfig, getUpstreamName } from "./storage" 4 | 5 | export function getGitHubNWO(url: string): string | undefined { 6 | // E.g. git@github.com:peterbe/admin-peterbecom.gi 7 | // or https://github.com/peterbe/admin-peterbecom.git" 8 | if (url.includes("github.com")) { 9 | if (URL.canParse(url)) { 10 | const parsed = new URL(url) 11 | return parsed.pathname.replace(/\.git$/, "").slice(1) 12 | } 13 | if (url.includes("git@github.com:")) { 14 | const second = url.split(":")[1] 15 | if (second) { 16 | return second.replace(/\.git$/, "") 17 | } 18 | } else { 19 | throw new Error(`Not implemented (${url})`) 20 | } 21 | } 22 | return url 23 | } 24 | 25 | export async function findPRByBranchName(branchName: string) { 26 | const octokit = await getOctokit() 27 | const [owner, repo] = await getOwnerRepo() 28 | const { data: prs } = await octokit.rest.pulls.list({ 29 | owner, 30 | repo, 31 | head: `${owner}:${branchName}`, 32 | state: "all", 33 | sort: "updated", 34 | direction: "desc", 35 | }) 36 | for (const pr of prs) { 37 | return pr 38 | } 39 | } 40 | 41 | export async function findBranchByBranchName(branchName: string) { 42 | const octokit = await getOctokit() 43 | const [owner, repo] = await getOwnerRepo() 44 | try { 45 | const { data } = await octokit.rest.repos.getBranch({ 46 | owner, 47 | repo, 48 | branch: branchName, 49 | }) 50 | return data 51 | } catch (err) { 52 | if (err instanceof Error && err.message.includes("Branch not found")) { 53 | return null 54 | } 55 | throw err 56 | } 57 | } 58 | 59 | export async function createGitHubPR({ 60 | head, 61 | base, 62 | title, 63 | body, 64 | draft = false, 65 | }: { 66 | head: string 67 | base: string 68 | title: string 69 | body: string 70 | draft?: boolean 71 | }) { 72 | const octokit = await getOctokit() 73 | console.log(`Creating PR from ${head} to ${base} with title "${title}"`) 74 | const [owner, repo] = await getOwnerRepo() 75 | const { data } = await octokit.rest.pulls.create({ 76 | owner, 77 | repo, 78 | title, 79 | head, 80 | base, 81 | body, 82 | draft, 83 | }) 84 | 85 | return data 86 | } 87 | 88 | // export async function enablePRAutoMerge({ prNumber, title, message = '', mergeMethod = 'merge' }: { 89 | // prNumber: number, 90 | // title: string, 91 | // message?: string 92 | // mergeMethod?: "merge" | "squash" | "rebase" }) { 93 | // const octokit = await getOctokit() 94 | // const [owner, repo] = await getOwnerRepo() 95 | // const { data } = await octokit.rest.pulls.createAutoMerge({ 96 | // owner, 97 | // repo, 98 | // pull_number: prNumber, 99 | // merge_method: mergeMethod, 100 | // commit_title: title 101 | // commit_message: message 102 | // }) 103 | // return data 104 | // } 105 | 106 | export async function getOwnerRepo(): Promise<[string, string]> { 107 | const git = simpleGit() 108 | const upstreamName = await getUpstreamName() 109 | const remotes = await git.getRemotes(true) // true includes URLs 110 | const origin = remotes.find((remote) => remote.name === upstreamName) 111 | const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 112 | if (!originUrl) { 113 | throw new Error( 114 | "Can't find an origin URL from the current remotes. Run `git remotes -v` to debug.", 115 | ) 116 | } 117 | const nwo = getGitHubNWO(originUrl) 118 | if (!nwo) 119 | throw new Error( 120 | `Could not figure out owner/repo from the URL: ${originUrl}`, 121 | ) 122 | const owner = nwo.split("/")[0] 123 | if (!owner) throw new Error(`Can't find owner part from '${nwo}'`) 124 | const repo = nwo.split("/").slice(1).join("/") 125 | if (!repo) throw new Error(`Can't find repo part from '${nwo}'`) 126 | 127 | return [owner, repo] 128 | } 129 | 130 | export async function getOctokit() { 131 | const config = await getGlobalConfig() 132 | const token = config["github-token"] 133 | if (!token) { 134 | throw new Error( 135 | "You have not set up a GitHub Personal Access Token. Run `github token`.", 136 | ) 137 | } 138 | 139 | const octokit = new Octokit({ auth: token }) 140 | return octokit 141 | } 142 | 143 | export async function getPRDetailsByNumber(number: number) { 144 | const octokit = await getOctokit() 145 | const [owner, repo] = await getOwnerRepo() 146 | 147 | const { data: prDetails } = await octokit.rest.pulls.get({ 148 | owner, 149 | repo, 150 | pull_number: number, 151 | }) 152 | return prDetails 153 | } 154 | 155 | type PRDetails = Awaited> 156 | export function interpretMergeableStatus(pr: PRDetails) { 157 | if (pr.state === "closed" && pr.merged) { 158 | return { 159 | canMerge: false, 160 | message: "PR is already merged", 161 | } 162 | } 163 | 164 | if (pr.state === "closed" && !pr.merged) { 165 | return { 166 | canMerge: false, 167 | message: "PR is CLOSED without merging", 168 | } 169 | } 170 | 171 | if (pr.draft) { 172 | return { 173 | canMerge: false, 174 | message: "PR is in DRAFT state", 175 | } 176 | } 177 | 178 | switch (pr.mergeable_state) { 179 | case "clean": 180 | return { 181 | canMerge: true, 182 | message: "PR is ready to merge - all checks passed", 183 | } 184 | 185 | case "dirty": 186 | return { 187 | canMerge: false, 188 | message: "PR has merge conflicts that need to be resolved", 189 | } 190 | 191 | case "blocked": 192 | return { 193 | canMerge: false, 194 | message: "PR is blocked by required status checks or reviews", 195 | } 196 | 197 | case "behind": 198 | return { 199 | canMerge: true, 200 | message: "PR can be merged but base branch has newer commits", 201 | hasWarning: true, 202 | } 203 | 204 | case "unstable": 205 | return { 206 | canMerge: true, 207 | message: "PR can be merged but some status checks failed", 208 | hasWarning: true, 209 | } 210 | 211 | case "has_hooks": 212 | return { 213 | canMerge: true, 214 | message: "PR can be merged but has pre-receive hooks", 215 | hasWarning: true, 216 | } 217 | 218 | case "unknown": 219 | return { 220 | canMerge: null, 221 | message: "Mergeable status is being calculated, try again in a moment", 222 | } 223 | 224 | default: 225 | if (pr.mergeable === true) { 226 | return { 227 | canMerge: true, 228 | message: "PR appears to be mergeable", 229 | } 230 | } else if (pr.mergeable === false) { 231 | return { 232 | canMerge: false, 233 | message: "PR has conflicts or other issues preventing merge", 234 | } 235 | } else { 236 | return { 237 | canMerge: null, 238 | message: "Mergeable status is null - GitHub is still calculating", 239 | } 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import { confirm, input } from "@inquirer/prompts" 2 | import kleur from "kleur" 3 | import { Octokit } from "octokit" 4 | import simpleGit, { type SimpleGit } from "simple-git" 5 | import { getCurrentBranch, getDefaultBranch } from "./branch-utils" 6 | import { deleteLocalBranch } from "./get-back" 7 | import { 8 | findPRByBranchName, 9 | getOwnerRepo, 10 | getPRDetailsByNumber, 11 | interpretMergeableStatus, 12 | } from "./github-utils" 13 | import { error, success, warn } from "./logger" 14 | import { getGlobalConfig, getUpstreamName, storeGlobalConfig } from "./storage" 15 | 16 | type TokenOptions = { 17 | test?: boolean 18 | testPrs?: boolean 19 | } 20 | 21 | export async function gitHubToken(token: string, options: TokenOptions) { 22 | const config = await getGlobalConfig() 23 | 24 | if (!token && config["github-token"] && (options.test || options.testPrs)) { 25 | await testToken(config["github-token"] as string, options) 26 | return 27 | } 28 | 29 | if (!token) { 30 | if (config["github-token"]) { 31 | warn( 32 | "You already have a token set. Use `token --test` if you just want to see if the existing one works.", 33 | ) 34 | } 35 | token = await input({ message: "Token:" }) 36 | if (!token) { 37 | throw new Error("No token entered") 38 | } 39 | await storeGlobalConfig("github-token", token) 40 | } 41 | 42 | await testToken(token, options) 43 | } 44 | 45 | async function testToken(token: string, options: TokenOptions): Promise { 46 | const octokit = new Octokit({ auth: token }) 47 | if (options.testPrs) { 48 | const octokit = new Octokit({ auth: token }) 49 | const [owner, repo] = await getOwnerRepo() 50 | try { 51 | const { data: prs } = await octokit.rest.pulls.list({ 52 | owner, 53 | repo, 54 | state: "open", 55 | sort: "updated", 56 | direction: "desc", 57 | }) 58 | success(`Found ${prs.length} open PRs which means it was able to connect`) 59 | } catch (err) { 60 | if (err instanceof Error && err.message.startsWith("Not Found")) { 61 | error( 62 | "It did not work. The GitHub repo could not be found. That most likely means that token does not have read-access permission.", 63 | ) 64 | } else { 65 | throw err 66 | } 67 | } 68 | } else { 69 | const { data: user } = await octokit.rest.users.getAuthenticated() 70 | console.log(user) 71 | success("The current stored GitHub token is working") 72 | } 73 | } 74 | 75 | type PROptions = { 76 | watch?: boolean 77 | } 78 | 79 | export async function gitHubPR(options: PROptions) { 80 | const watch = Boolean(options.watch) 81 | const git = simpleGit() 82 | const currentBranch = await getCurrentBranch(git) 83 | const defaultBranch = await getDefaultBranch(git) 84 | if (defaultBranch === currentBranch) { 85 | throw new Error( 86 | `You are on the default branch (${defaultBranch}). Switch to a feature branch first.`, 87 | ) 88 | } 89 | 90 | const pr = await findPRByBranchName(currentBranch) 91 | if (!pr) { 92 | warn("Pull request not found.") 93 | return 94 | } 95 | 96 | success( 97 | `Number #${pr.number} ${kleur.bold(pr.html_url)} ${ 98 | pr.draft ? "DRAFT" : pr.state.toUpperCase() 99 | }`, 100 | ) 101 | 102 | const prDetails = await getPRDetailsByNumber(pr.number) 103 | 104 | console.log(kleur.bold(`PR Title: ${prDetails.title}`)) 105 | const { message, canMerge, hasWarning } = interpretMergeableStatus(prDetails) 106 | if (canMerge && !hasWarning) success(message) 107 | else warn(message) 108 | 109 | if (prDetails.mergeable_state === "behind") { 110 | await isBehind({ git, defaultBranch, currentBranch }) 111 | } else if (prDetails.state === "closed" && prDetails.merged) { 112 | await getBack({ git, defaultBranch, currentBranch }) 113 | } else if (prDetails.auto_merge) { 114 | success("Can auto-merge!") 115 | } 116 | 117 | if (watch) { 118 | let previousMessage = message 119 | let previousCanMerge = canMerge 120 | let attempts = 0 121 | const SLEEP_TIME_SECONDS = 5 122 | while (true) { 123 | console.log( 124 | `Watching (checking every ${SLEEP_TIME_SECONDS} seconds, attempt number ${attempts + 1})...`, 125 | ) 126 | await sleep(SLEEP_TIME_SECONDS * 1000) 127 | const prDetails = await getPRDetailsByNumber(pr.number) 128 | const { message, canMerge, hasWarning } = 129 | interpretMergeableStatus(prDetails) 130 | console.clear() 131 | console.log(kleur.bold(`PR Title: ${prDetails.title}`)) 132 | if (canMerge && !hasWarning) success(message) 133 | else warn(message) 134 | 135 | if (message !== previousMessage || canMerge !== previousCanMerge) { 136 | success("Output changed, so quitting the watch") 137 | break 138 | } 139 | previousMessage = message 140 | previousCanMerge = canMerge 141 | attempts++ 142 | if (attempts > 100) { 143 | warn(`Sorry. Giving up on the PR watch after ${attempts} attempts.`) 144 | break 145 | } 146 | } 147 | } 148 | } 149 | 150 | async function sleep(ms: number) { 151 | return new Promise((resolve) => setTimeout(resolve, ms)) 152 | } 153 | 154 | async function isBehind({ 155 | git, 156 | defaultBranch, 157 | currentBranch, 158 | }: { 159 | git: SimpleGit 160 | defaultBranch: string 161 | currentBranch: string 162 | }) { 163 | warn("PR appears to be be behind the base branch") 164 | const attemptMastermerge = await confirm({ 165 | message: `Attempt to merge upstream ${kleur.italic(defaultBranch)} into ${kleur.italic(currentBranch)} now?`, 166 | default: false, 167 | }) 168 | if (attemptMastermerge) { 169 | const upstreamName = await getUpstreamName() 170 | 171 | const remotes = await git.getRemotes(true) // true includes URLs 172 | const origin = remotes.find((remote) => remote.name === upstreamName) 173 | if (!origin?.name) { 174 | throw new Error(`Could not find a remote called '${upstreamName}'`) 175 | } 176 | const originName = origin.name 177 | await git.fetch(originName, defaultBranch) 178 | 179 | await git.mergeFromTo(originName, defaultBranch) 180 | 181 | success( 182 | `Latest ${originName}/${defaultBranch} branch merged into this branch.`, 183 | ) 184 | 185 | let pushToRemote = false 186 | if (!pushToRemote && origin) { 187 | pushToRemote = await confirm({ 188 | message: `Push to ${originName}:`, 189 | default: true, 190 | }) 191 | } 192 | 193 | if (pushToRemote) { 194 | await git.push(upstreamName, currentBranch) 195 | success(`Changes pushed to ${originName}/${currentBranch}`) 196 | } 197 | } 198 | } 199 | 200 | async function getBack({ 201 | git, 202 | defaultBranch, 203 | currentBranch, 204 | }: { 205 | git: SimpleGit 206 | defaultBranch: string 207 | currentBranch: string 208 | }) { 209 | const status = await git.status() 210 | if (!status.isClean()) { 211 | return 212 | } 213 | 214 | success("PR has been merged!") 215 | const goBack = await confirm({ 216 | message: `Go back to branch ${kleur.italic(defaultBranch)} and clean this branch up?`, 217 | default: true, 218 | }) 219 | if (goBack) { 220 | await git.checkout(defaultBranch) 221 | 222 | const upstreamName = await getUpstreamName() 223 | 224 | const remotes = await git.getRemotes(true) 225 | const origin = remotes.find((remote) => remote.name === upstreamName) 226 | if (origin) { 227 | await git.pull(origin.name, defaultBranch) 228 | warn(`Going to delete branch ${kleur.italic(currentBranch)}`) 229 | await deleteLocalBranch({ git, currentBranch, defaultBranch, yes: false }) 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/commit-branch.ts: -------------------------------------------------------------------------------- 1 | import { checkbox, confirm, input } from "@inquirer/prompts" 2 | import kleur from "kleur" 3 | import simpleGit, { type SimpleGit } from "simple-git" 4 | import { 5 | getCurrentBranch, 6 | getDefaultBranch, 7 | getUnstagedFiles, 8 | getUntrackedFiles, 9 | } from "./branch-utils" 10 | import { 11 | createGitHubPR, 12 | findPRByBranchName, 13 | getGitHubNWO, 14 | getPRDetailsByNumber, 15 | interpretMergeableStatus, 16 | } from "./github-utils" 17 | import { getHumanAge } from "./human-age" 18 | import { success, warn } from "./logger" 19 | import { 20 | getBaseBranch, 21 | getRepoConfig, 22 | getTitle, 23 | getUpstreamName, 24 | } from "./storage" 25 | 26 | type Options = { 27 | verify?: boolean 28 | yes?: boolean 29 | } 30 | 31 | export async function commitBranch(message: string, options: Options) { 32 | const yes = Boolean(options.yes) 33 | const noVerify = !options.verify 34 | const git = simpleGit() 35 | const currentBranch = await getCurrentBranch(git) 36 | const defaultBranch = await getDefaultBranch(git) 37 | if (defaultBranch === currentBranch) { 38 | throw new Error( 39 | `You are on the default branch (${defaultBranch}). Switch to a feature branch before committing.`, 40 | ) 41 | } 42 | 43 | const untrackedFiles = await getUntrackedFiles(git) 44 | if (untrackedFiles.length > 0) { 45 | warn("\nUntracked files:") 46 | await printUnTrackedFiles(untrackedFiles) 47 | console.log("") 48 | const confirmed = await input({ 49 | message: "Do you want to add these untracked files? [y/n/i]", 50 | }) 51 | if (confirmed.toLowerCase() === "n") { 52 | } else if (confirmed.toLowerCase() === "y") { 53 | await git.add(untrackedFiles) 54 | } else if (confirmed.toLowerCase() === "i") { 55 | // Interactive 56 | const choices: { name: string; value: string }[] = [] 57 | for (const name of untrackedFiles) { 58 | const file = Bun.file(name) 59 | const stats = await file.stat() 60 | choices.push({ 61 | value: name, 62 | name: `${name} (${getHumanAge(stats.mtime)})`, 63 | }) 64 | } 65 | const answer = await checkbox({ 66 | message: "Select which untracked files to add (use spacebar to select)", 67 | choices, 68 | }) 69 | await git.add(answer) 70 | } else { 71 | throw new Error("Invalid input. Please enter 'Y' or 'n'.") 72 | } 73 | } 74 | 75 | // If there was an explicit (commit) message passed at the invocation, 76 | // don't bother asking. 77 | let title = message 78 | if (!title) { 79 | const storedTitle = await getTitle(currentBranch) 80 | title = await input({ message: "Title:", default: storedTitle }) 81 | if (!title && storedTitle) { 82 | title = storedTitle 83 | } 84 | if (!title) { 85 | throw new Error( 86 | "No title provided. Please provide a title for the commit.", 87 | ) 88 | } 89 | } 90 | 91 | const upstreamName = await getUpstreamName() 92 | 93 | const remotes = await git.getRemotes(true) // true includes URLs 94 | const origin = remotes.find((remote) => remote.name === upstreamName) 95 | const originUrl = origin ? origin.refs.fetch : null // or origin.refs.push 96 | const originName = origin ? origin.name : upstreamName 97 | 98 | const unstagedFiles = await getUnstagedFiles(git) 99 | await git.add(unstagedFiles) 100 | 101 | try { 102 | await commit(git, title, noVerify) 103 | } catch (error) { 104 | console.warn("An error happened when trying to commmit!") 105 | if ( 106 | yes && 107 | error instanceof Error && 108 | error.message.includes("nothing to commit, working tree clean") 109 | ) { 110 | await git.push(upstreamName, currentBranch) 111 | success(`Changes pushed to ${originName}/${currentBranch}`) 112 | } else { 113 | throw error 114 | } 115 | } 116 | 117 | let pushToRemote = yes 118 | if (!pushToRemote && origin) { 119 | pushToRemote = await confirm({ 120 | message: `Push to ${originName}:`, 121 | default: true, 122 | }) 123 | } 124 | 125 | if (pushToRemote) { 126 | await git.push(upstreamName, currentBranch) 127 | success(`Changes pushed to ${originName}/${currentBranch}`) 128 | } else { 129 | success("Changes committed but not pushed.") 130 | } 131 | console.log("\n") 132 | const nwo = pushToRemote && originUrl && getGitHubNWO(originUrl) 133 | if (nwo) { 134 | const pr = await findPRByBranchName(currentBranch) 135 | 136 | if (pr) { 137 | console.log(kleur.bold(pr.title)) 138 | console.log(kleur.bold().green(pr.html_url)) 139 | 140 | // Force a slight delay because sometimes it says the PR is 141 | // ready to merge, even though you've just pushed more commits. 142 | await sleep(2000) 143 | 144 | let prDetails = await getPRDetailsByNumber(pr.number) 145 | let retries = 3 146 | while (prDetails.mergeable_state === "unknown" && retries-- > 0) { 147 | warn(`PR mergeable state is unknown. Trying again... (${retries})`) 148 | // Wait a bit and try again 149 | await sleep(2000) 150 | prDetails = await getPRDetailsByNumber(pr.number) 151 | } 152 | 153 | const { message, canMerge, hasWarning } = 154 | interpretMergeableStatus(prDetails) 155 | if (canMerge && !hasWarning) success(message) 156 | else warn(message) 157 | } else { 158 | // e.g. https://github.com/peterbe/admin-peterbecom/pull/new/upgrade-playwright 159 | let url: string 160 | const storedBaseBranch = await getBaseBranch(currentBranch) 161 | if (storedBaseBranch && storedBaseBranch !== defaultBranch) { 162 | url = `https://github.com/${nwo}/compare/${storedBaseBranch}...${currentBranch}?expand=1` 163 | } else { 164 | url = `https://github.com/${nwo}/pull/new/${currentBranch}` 165 | } 166 | console.log(kleur.bold().green(url)) 167 | } 168 | console.log("(⌘-click to open URLs)") 169 | 170 | const config = await getRepoConfig() 171 | const disablePRCreation = config["disable-pr-creation"] 172 | if (!pr && !disablePRCreation) { 173 | console.log("") 174 | const createPr = await confirm({ 175 | message: `Create new PR:`, 176 | default: yes, 177 | }) 178 | if (createPr) { 179 | const storedTitle = await getTitle(currentBranch) 180 | const message = "Title:" 181 | const title = await input({ message, default: storedTitle }) 182 | const storedBaseBranch = await getBaseBranch(currentBranch) 183 | 184 | const baseBranch = 185 | storedBaseBranch && storedBaseBranch !== defaultBranch 186 | ? await input({ 187 | message: "Base branch:", 188 | default: storedBaseBranch, 189 | }) 190 | : defaultBranch 191 | 192 | const data = await createGitHubPR({ 193 | head: currentBranch, 194 | base: baseBranch, 195 | title, 196 | body: "", 197 | draft: false, 198 | }) 199 | console.log("Pull request created:") 200 | console.log(kleur.bold().green(data.html_url)) 201 | } 202 | } 203 | } 204 | } 205 | 206 | async function commit( 207 | git: SimpleGit, 208 | title: string, 209 | noVerify: boolean, 210 | ): Promise { 211 | if (noVerify) { 212 | await git.commit(title, ["--no-verify"]) 213 | } else { 214 | // If the pre-commit hook prints errors, with colors, 215 | // using this Bun.spawn is the only way I know to preserve those outputs 216 | // in color. 217 | // This also means, that when all goes well, it will print too. 218 | const proc = Bun.spawn(["git", "commit", "-m", title]) 219 | 220 | const exited = await proc.exited 221 | 222 | if (exited) { 223 | if (!noVerify) { 224 | console.log("\n") 225 | warn("Commit failed and you did not use --no-verify.") 226 | const retry = await confirm({ 227 | message: "Try again but with --no-verify?", 228 | default: false, 229 | }) 230 | if (retry) { 231 | warn(`Retrying commit ${kleur.italic("with")} --no-verify...`) 232 | await commit(git, title, true) 233 | success("Commit succeeded with --no-verify") 234 | return 235 | } 236 | } 237 | 238 | console.log("\n") 239 | warn("Warning! The git commit failed.") 240 | warn( 241 | "Hopefully the printed error message above is clear enough. You'll have to try to commit again.", 242 | ) 243 | process.exit(exited) 244 | } 245 | } 246 | } 247 | 248 | async function printUnTrackedFiles(files: string[]) { 249 | const longestFileName = Math.max(...files.map((file) => file.length)) 250 | for (const filePath of files) { 251 | const file = Bun.file(filePath) 252 | const stats = await file.stat() 253 | const age = getHumanAge(stats.mtime) 254 | console.log(`${filePath.padEnd(longestFileName + 10, " ")} ${age} old`) 255 | } 256 | } 257 | 258 | async function sleep(ms: number) { 259 | return new Promise((resolve) => setTimeout(resolve, ms)) 260 | } 261 | --------------------------------------------------------------------------------