diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index f804352..9133915 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -4,6 +4,11 @@ name: 'Release Main' on: workflow_dispatch: + inputs: + dry_run: + description: 'Run through beta/prod without pushing to main (verification only)' + type: boolean + default: false permissions: contents: write @@ -18,23 +23,14 @@ jobs: fetch-depth: 0 ref: develop - - name: Set up Node.js - uses: tomgrv/actions/setup-node@v2 - - - name: Install GitUtils - run: npm exec --yes --legacy-peer-deps --package github:tomgrv/devcontainer-features -- devcontainer-features -- add gitutils - - - name: Verify GitUtils aliases - run: | - git config --global --get alias.beta >/dev/null || { echo 'Missing git beta alias after GitUtils install; check the "Install GitUtils" step logs.'; false; } - git config --global --get alias.prod >/dev/null || { echo 'Missing git prod alias after GitUtils install; check the "Install GitUtils" step logs.'; false; } - - - name: Configure git identity - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - - name: Run git beta then git prod - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: git beta && git prod + - name: Release promote + # TODO: pin to a released tag of tomgrv/actions once its + # multi-repo-consistency PR merges and tags (currently pointing at + # the working branch so this repo's own PR can be reviewed + # alongside it). + uses: tomgrv/actions/release-promote@claude/multi-repo-consistency-release-7se0jy + with: + # TODO: pin to a released tag of tomgrv/scripts once this repo's + # own PR merges and tags. + scripts-ref: claude/multi-repo-consistency-release-7se0jy + dry-run: ${{ inputs.dry_run == true }} diff --git a/.gitversion b/.gitversion new file mode 100644 index 0000000..bd951cf --- /dev/null +++ b/.gitversion @@ -0,0 +1,3 @@ +major-version-bump-message: "^(build|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([\\w\\s-]*\\))?(!:|:.*\\n\\n((.+\\n)+\\n)?BREAKING CHANGE:\\s.+)" +minor-version-bump-message: "^(feat)(\\([\\w\\s-]*\\))?:" +patch-version-bump-message: "^(build|ci|docs|fix|perf|refactor|revert|style|test)(\\([\\w\\s-]*\\))?:" diff --git a/README.md b/README.md index 1b8b37e..58fc0d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + + # scripts Reusable shell scripts shared across @@ -31,7 +33,7 @@ shell scripts instead of composite GitHub Actions. ## `setup.sh` — root bootstrap -A machine with nothing installed yet needs *something* fetchable with zero +A machine with nothing installed yet needs _something_ fetchable with zero prerequisites. That's `setup.sh`, kept deliberately dumb and DRY: it downloads a tarball of this repo to a temp dir, then hands off to the `zz_use` it just downloaded to install the core `zz_*` bundle from @@ -74,7 +76,7 @@ round-trip. core `zz_*` scripts from it: ```sh -zz_update # or: zz_use --force +zz_update # or: zz_use --force ``` Any tool name accepts an optional `[org/repo/]` prefix and/or `@` @@ -125,7 +127,7 @@ context" (a checkout, a warm cache, or a freshly downloaded tarball — all three are just a directory of `zz_*/run.sh` siblings) and symlink every script in it onto `PATH` under its real name, in a throwaway scratch dir. From that point on, `command -v zz_bindir`, `zz_log ...`, even the -`. zz_colors` *inside* zz_bindir's and zz_log's own source, all just +`. zz_colors` _inside_ zz_bindir's and zz_log's own source, all just resolve normally — zero reimplementation of what those scripts do. That's what lets `zz_use` bootstrap the whole core `zz_*` bundle from nothing: the very first `zz_use zz_colors ...` a freshly downloaded, standalone @@ -141,11 +143,11 @@ For each `` requested, in order: 3. **Any other tool with a `zz_use/config/zz_use.json` entry** (override with `ZZ_USE_CONFIG`) — an explicit mapping always wins if a name happens to collide with a repo script: - - `{"apt": ""}` → `apt-get install -y ` (via `sudo` if not root). - - `{"url": ..., "archive": "tar.gz"|"tar.xz"|"zip"|"raw", "binpath": ...}` - → download, extract if needed, resolve a writable bin dir via - `zz_bindir`, and install the binary as ``. Templates support - `{VERSION}`, `{OS}` (`uname -s`, lowercased), `{ARCH}` (`amd64`/`arm64`). + - `{"apt": ""}` → `apt-get install -y ` (via `sudo` if not root). + - `{"url": ..., "archive": "tar.gz"|"tar.xz"|"zip"|"raw", "binpath": ...}` + → download, extract if needed, resolve a writable bin dir via + `zz_bindir`, and install the binary as ``. Templates support + `{VERSION}`, `{OS}` (`uname -s`, lowercased), `{ARCH}` (`amd64`/`arm64`). 4. **Any other script from this repo** (a functional script like `load-json`, or a core one requested individually) — installed on its own, not as part of the bundle: unlike the core set, functional @@ -163,46 +165,93 @@ the tool isn't already available. ## Core `zz_*` scripts -| Script | Purpose | -| ----------------------------- | --------------------------------------------------------------------- | -| `zz_use [@ref]` | the activator: resolve/install a dependency, if and only if missing (see below) | -| `zz_update` | force a fresh download of the zz_* bundle, bypassing the local cache | -| `zz_colors` | ANSI color vars (`$Red` `$Green` ... `$End`); source it: `. zz_colors` | -| `zz_log ` | colored, leveled log line on stderr (`i`/`w`/`e`/`s`/`-`) | -| `zz_args <caller> <<-help ...` | parse `$@` per a spec; `eval $(zz_args ...)` to bind the vars | -| `zz_prompt <question> [default]` | interactive free-form input | -| `zz_ask <options> <question...>` | interactive single-char confirm | -| `zz_input [file]` | read from arg (literal or file) or stdin | -| `zz_bindir [-t target]` | resolve/create a writable bin dir; `eval $(zz_bindir ...)` to bind `$dir` and extend `PATH` | -| `zz_dispatch <caller> <subcmd>` | dispatch an underscore-prefixed caller to a sibling `<name>-<subcmd>` script | -| `zz_npx [-s] <tool>` | run a local `node_modules/.bin` binary, falling back to `npx` | -| `zz_persist [-f\|-p] <key> <value>` | upsert a `KEY=VALUE` pair into an env file and/or `/etc/profile.d` | +| Script | Purpose | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `zz_use <tool>[@ref]` | the activator: resolve/install a dependency, if and only if missing (see below) | +| `zz_update` | force a fresh download of the zz_* bundle, bypassing the local cache | +| `zz_colors` | ANSI color vars (`$Red` `$Green` ... `$End`); source it: `. zz_colors` | +| `zz_log <lvl> <msg...>` | colored, leveled log line on stderr (`i`/`w`/`e`/`s`/`-`) | +| `zz_args <title> <caller> <<-help ...` | parse `$@` per a spec; `eval $(zz_args ...)` to bind the vars | +| `zz_prompt <question> [default]` | interactive free-form input | +| `zz_ask <options> <question...>` | interactive single-char confirm | +| `zz_input [file]` | read from arg (literal or file) or stdin | +| `zz_bindir [-t target]` | resolve/create a writable bin dir; `eval $(zz_bindir ...)` to bind `$dir` and extend `PATH` | +| `zz_dispatch <caller> <subcmd>` | dispatch an underscore-prefixed caller to a sibling `<name>-<subcmd>` script | +| `zz_npx [-s] <tool>` | run a local `node_modules/.bin` binary, falling back to `npx` | +| `zz_persist [-f\|-p] <key> <value>` | upsert a `KEY=VALUE` pair into an env file and/or `/etc/profile.d` | | `zz_call [-p package.json] [command...]` | resolve a caller's declared env vars (`config.input`/`config.output` in `package.json`; ask + persist if missing), then run a command | ## Functional scripts -| Script | Purpose | -| --------------------- | ------------------------------------------------------------------------ | -| `load-json` | load JSON from a file/URL, tag it with `$id` | -| `validate-json` | validate JSON against a (local/inferred/remote) JSON Schema | -| `normalize-json` | sort JSON keys per schema + alphabetically, optional in-place write | -| `merge-json` | recursively merge one JSON file into another (arrays deduped, unioned) | -| `resolve-context` | resolve a feature's source/target dirs from the calling script | -| `edit-script` | copy an installed script locally and open it for editing | -| `distribute-utils` | copy `zz_*`/utility scripts into a project's local scripts directory | -| `install-feature` | copy a feature's stubs/config/bin into a target, run `install-*.sh` | -| `configure-feature` | deploy a feature's stubs into the cwd (merging), run `configure-*.sh` | +| Script | Purpose | +| ------------------- | ---------------------------------------------------------------------- | +| `load-json` | load JSON from a file/URL, tag it with `$id` | +| `validate-json` | validate JSON against a (local/inferred/remote) JSON Schema | +| `normalize-json` | sort JSON keys per schema + alphabetically, optional in-place write | +| `merge-json` | recursively merge one JSON file into another (arrays deduped, unioned) | +| `resolve-context` | resolve a feature's source/target dirs from the calling script | +| `edit-script` | copy an installed script locally and open it for editing | +| `distribute-utils` | copy `zz_*`/utility scripts into a project's local scripts directory | +| `install-feature` | copy a feature's stubs/config/bin into a target, run `install-*.sh` | +| `configure-feature` | deploy a feature's stubs into the cwd (merging), run `configure-*.sh` | See each folder's own `README.md` for its usage line. +## Git utilities + +Migrated from `tomgrv/devcontainer-features`'s `gitutils` feature (which +used to ship them directly under `src/gitutils/bin/`), mirroring the same +move `common-utils`'s functional scripts made earlier — one source of +truth here, fetched on demand via `zz_use` instead of duplicated per +consumer. Installed as `git-<name>` on `PATH`, so git resolves them as +`git <name>` subcommands (e.g. `git-release-beta` → `git release-beta`). +The `gitutils` feature still owns the config (which aliases like `git +beta`/`git prod` point at which of these) and the git-flow install/config +lifecycle — only the script implementations moved. + +| Script | Purpose | +| -------------------- | ----------------------------------------------------------- | +| `git-align` | align the current branch with its remote counterpart | +| `git-autorebase` | non-interactive rebasing with conflict resolution | +| `git-co` | enhanced commit | +| `git-degit` | clone and degit a repository | +| `git-fix` | dispatch to `git-fix-<subcommand>` | +| `git-fix-author` | set `user.name`/`user.email` to a specified commit's author | +| `git-fix-base` | rebase commits from one branch onto another | +| `git-fix-blanks` | discard whitespace/blank/quote-slash-only changes | +| `git-fix-children` | delete all descendant tags and branches of a commit | +| `git-fix-date` | fix commit dates/times in history | +| `git-fix-del` | delete a specified commit and rebase subsequent history | +| `git-fix-emoji` | fix git emoji | +| `git-fix-last` | edit the last commit's message and content | +| `git-fix-lock` | resolve conflicts and regenerate lock files | +| `git-fix-message` | rewrite an arbitrary commit message | +| `git-fix-mode` | fix file mode changes from diff | +| `git-fix-privacy` | fix privacy in history | +| `git-fix-prune` | prune stale remote-tracking references | +| `git-fix-rights` | set appropriate file/directory permissions | +| `git-fix-secrets` | redact a secret across git history | +| `git-fix-up` | amend a commit with current changes and rebase | +| `git-forall` | execute a command for all files in the repository | +| `git-getcommit` | list history and ask for a commit to fix up | +| `git-integrate` | integrate modifications from the remote repository | +| `git-pick` | pick files from a specific commit | +| `git-release` | dispatch to `git-release-<subcommand>` | +| `git-release-alpha` | squash-merge the current feature branch into develop | +| `git-release-beta` | start a release branch via Git Flow | +| `git-release-hotfix` | start a hotfix branch via Git Flow | +| `git-release-prod` | finish a release/hotfix branch via Git Flow | +| `git-unset` | unset all git config keys starting with a given prefix | +| `git-workspaces` | list workspace directories and affected workspaces | + ## Usage Install the whole workspace, or a single script's own package: ```sh -npm install --save-dev @tomgrv/scripts # everything +npm install --save-dev @tomgrv/scripts # everything # or, e.g.: -npm install --save-dev ./validate-json # just this one, standalone +npm install --save-dev ./validate-json # just this one, standalone ``` Every functional script is self-contained: `zz_use zz_colors zz_args ...` @@ -213,6 +262,6 @@ Any single folder can be copied out and still work standalone. ## Tests ```sh -npm test # bats --recursive . (every */test.bats) -bats validate-json/test.bats # a single script's tests +npm test # bats --recursive . (every */test.bats) +bats validate-json/test.bats # a single script's tests ``` diff --git a/git-align/README.md b/git-align/README.md new file mode 100644 index 0000000..5704790 --- /dev/null +++ b/git-align/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-align + +Align the current branch with its remote counterpart. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-align` (invoke via `git align`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-align/package.json b/git-align/package.json new file mode 100644 index 0000000..6026618 --- /dev/null +++ b/git-align/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-align", + "version": "1.0.0", + "description": "Align the current branch with its remote counterpart", + "private": true, + "bin": { + "git-align": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-align/run.sh b/git-align/run.sh new file mode 100755 index 0000000..e4a4ce1 --- /dev/null +++ b/git-align/run.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# get the branch name whatever the form is +branch=$(git rev-parse --abbrev-ref HEAD) + +# get the remote branch name +origin=$(git config --get branch.$branch.remote) + +# Stash all changes, including untracked files, with a message +# Record the stash ref before/after so we only pop when something was stashed +# (a clean tree stashes nothing, and an unconditional pop would consume an +# unrelated, older stash entry). +before=$(git rev-parse -q --verify refs/stash 2>/dev/null) +git stash push -u -m "Stashing changes before aligning branch" +after=$(git rev-parse -q --verify refs/stash 2>/dev/null) + +# Fetch the latest changes from the remote and align the branch +git fetch $origin +git branch -m $branch $branch-to-delete +if git checkout -b $branch $origin/$branch; then + git branch -D $branch-to-delete +else + zz_log e "Failed to checkout branch $branch from $origin/$branch" + git branch -m $branch-to-delete $branch +fi +# Apply the stashed changes only if a new stash entry was created +[ "$before" != "$after" ] && git stash pop + +# Print the current branch name +zz_log i "Current branch: $(git rev-parse --abbrev-ref HEAD)" diff --git a/git-align/test.bats b/git-align/test.bats new file mode 100644 index 0000000..8593689 --- /dev/null +++ b/git-align/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-align is installed on PATH and syntactically valid" { + command -v git-align + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-autorebase/README.md b/git-autorebase/README.md new file mode 100644 index 0000000..e8bf729 --- /dev/null +++ b/git-autorebase/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-autorebase + +Automatically handle non-interactive rebasing with conflict resolution. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-autorebase` (invoke via `git autorebase`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-autorebase/package.json b/git-autorebase/package.json new file mode 100644 index 0000000..86fb379 --- /dev/null +++ b/git-autorebase/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-autorebase", + "version": "1.0.0", + "description": "Automatically handle non-interactive rebasing with conflict resolution", + "private": true, + "bin": { + "git-autorebase": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-autorebase/run.sh b/git-autorebase/run.sh new file mode 100755 index 0000000..6faa921 --- /dev/null +++ b/git-autorebase/run.sh @@ -0,0 +1,99 @@ +#!/bin/sh + +# Handle parameters +eval $( + zz_args "Automatically handles non-interactive rebasing with conflict resolution" $0 "$@" <<-help + f - force allow overwriting pushed history + p - push push to remote after rebase + a - autosquash Apply autosquash (default: off) + n - no-lock don't automatically manage lock files + s strategy strategy strategy for conflicts (default: theirs) + o onto onto rebase onto (default: origin/main) + b branch branch branch to rebase (default: current branch) + - sha sha commit/branch to rebase onto (default: origin/main) +help +) + +# Set defaults +strategy="${strategy:-theirs}" +branch="${branch:-$(git branch --show-current)}" +sha=$(git getcommit $force $sha) +lockfile_patterns="${no_lock:+}${no_lock:-package-lock.json yarn.lock composer.lock pnpm-lock.yaml go.sum}" + +# Navigate to repository root and validate state +cd "$(git rev-parse --show-toplevel)" >/dev/null +git sync >/dev/null 2>&1 || git fetch --prune origin >/dev/null 2>&1 + +if git isRebase; then + zz_log e 'Rebase is in progress, please finish or abort it first.' + exit 1 +fi + +# Manage lockfiles during conflicts +manage_lockfiles() { + + for pattern in $lockfile_patterns; do + find . -name "$pattern" -type f | while read lockfile; do + if git status --porcelain | grep -q "^UU.*$lockfile"; then + zz_log i "Resolving lock file conflict: $lockfile" + git checkout --theirs "$lockfile" && git add "$lockfile" + fi + done + done +} + +# Handle all conflicts automatically +handle_conflicts() { + git conflict | while read file; do + zz_log i "Resolving conflict in: $file" + case "$strategy" in + "theirs") git checkout --theirs "$file" ;; + "ours") git checkout --ours "$file" ;; + esac + git add "$file" + done + manage_lockfiles +} + + +current_branch=$(git branch --show-current) +zz_log i "Rebasing '$current_branch' onto '$sha' with '$strategy' strategy" + +# Perform rebase with automatic conflict resolution +if ! git rebase --strategy-option="$strategy" ${auto:+--autosquash} --autostash --reschedule-failed-exec --exec 'git hook run --ignore-missing pre-commit -- HEAD HEAD~1 && git commit --amend --no-edit --no-verify' --no-verify ${onto:+--onto "$onto"} "$sha" "$branch"; then + zz_log w "Resolving conflicts automatically..." + + attempts=0 + while git status | grep -q "rebase in progress" && [ $attempts -lt 50 ]; do + attempts=$((attempts + 1)) + + if git conflict | grep -q .; then + zz_log i "Handling conflicts (attempt $attempts)" + handle_conflicts + fi + + git rebase --continue || break + sleep 0.1 + done + + # Check if rebase completed successfully + if git status | grep -q "rebase in progress"; then + zz_log e "Rebase failed after $attempts attempts" + git abort + exit 1 + fi +else + zz_log s "Rebase completed without conflicts" +fi + +# Push changes if requested +if [ -n "$push" ] && git rev-parse --verify --quiet origin/HEAD >/dev/null; then + zz_log i "Pushing changes..." + if [ -n "$force" ]; then + git pf origin HEAD + else + git push origin HEAD + fi +fi + +zz_log s "Rebase completed: '$current_branch' → '$sha'" diff --git a/git-autorebase/test.bats b/git-autorebase/test.bats new file mode 100644 index 0000000..02ee3f3 --- /dev/null +++ b/git-autorebase/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-autorebase is installed on PATH and syntactically valid" { + command -v git-autorebase + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-co/README.md b/git-co/README.md new file mode 100644 index 0000000..de85136 --- /dev/null +++ b/git-co/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-co + +Git enhanced commit. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-co` (invoke via `git co`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-co/package.json b/git-co/package.json new file mode 100644 index 0000000..87df4f7 --- /dev/null +++ b/git-co/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-co", + "version": "1.0.0", + "description": "Git enhanced commit", + "private": true, + "bin": { + "git-co": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-co/run.sh b/git-co/run.sh new file mode 100755 index 0000000..cb5c89e --- /dev/null +++ b/git-co/run.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# Function to print help and manage arguments + +eval $( + zz_args "git enhanced commit" $0 "$@" <<-help + n - noscope Enforce no scope in commit message + s scope scope Scope to use in commit message + b branch branch Branch to commit to + + msg msg Commit message + help +) + +if [ -z "$msg" ]; then + zz_log e "Commit message is required" + exit 1 +fi + +# Check if a scope is provided as (...) in the commit message +if [ -z "$noscope" ] && [ -z "$scope" ]; then + + zz_log i "No scope provided, using current branch name as scope" + + # Get feature branch configuration + prefix=$(git config gitflow.prefix.feature) + + # Get scope from the arguments or current branch + if [ -n "$prefix" ]; then + scope=$(git rev-parse --abbrev-ref HEAD | grep "^$prefix" | sed "s|$prefix||") + fi +fi + +if echo $msg | grep -q '^[^(]*([^)]*)'; then + zz_log w "Scope already set in commit message" +elif [ -n "$scope" ]; then + zz_log i "Injecting scope {B $scope} into commit message" + msg="$(echo "$msg" | sed 's/:.*$//')($scope): $(echo "$msg" | sed 's/^[^:]*://')" +elif [ -n "$noscope" ]; then + zz_log i "Removing scope from commit message" + msg="$(echo "$msg" | sed 's/^[^(]*([^)]*)//')" +fi + +git commit -m "$msg" + +if [ -n "$branch" ]; then + zz_log i "Rebasing branch $branch on top of current branch" + git stash && git rebase $branch && git stash pop +fi diff --git a/git-co/test.bats b/git-co/test.bats new file mode 100644 index 0000000..9bae324 --- /dev/null +++ b/git-co/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-co is installed on PATH and syntactically valid" { + command -v git-co + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-degit/README.md b/git-degit/README.md new file mode 100644 index 0000000..80db919 --- /dev/null +++ b/git-degit/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-degit + +Clone and degit a repository. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-degit` (invoke via `git degit`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-degit/package.json b/git-degit/package.json new file mode 100644 index 0000000..851a106 --- /dev/null +++ b/git-degit/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-degit", + "version": "1.0.0", + "description": "Clone and degit a repository", + "private": true, + "bin": { + "git-degit": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-degit/run.sh b/git-degit/run.sh new file mode 100755 index 0000000..0401e97 --- /dev/null +++ b/git-degit/run.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +# Check if repository is provided +if [ "$#" -lt 1 ]; then + echo "Usage: $0 <github repository> [<directory>]" + exit 1 +fi + +# Function to print help and manage arguments +eval $( + zz_args "Clone and degit a repository" $0 "$@" <<-help + - repo repo repository to clone + - directory directory directory to degit into + help +) + +# Get the repository host +host=$(echo "${repo}" | sed -E 's/https?:\/\/([^/]+)\/.*/\1/') + +# Keep only the repository name. Eventually remove .git suffix +repo=$(echo "${repo}" | sed -E -e 's/.*github.com\/([^/]+)\/([^/]+).*/\1\/\2/' -e 's/\.git$//') + +# Check if the directory is provided +if [ -z "${directory}" ]; then + directory=. +fi + +# Trace +zz_log i "Repository: ${repo}" +zz_log i "Directory: ${directory}" + +# Create directory if it doesn't exist +mkdir -p "${directory}" + +# Download and extract repository per host +case $host in +"gitlab.com") + curl --location "https://gitlab.com/${repo}/-/archive/master/${repo}-master.tar.gz" | + tar --extract --ungzip --strip-components=1 --directory "${directory}" + ;; +"bitbucket.org") + curl --location "https://bitbucket.org/${repo}/get/master.tar.gz" | + tar --extract --ungzip --strip-components=1 --directory "${directory}" + ;; +"github.com") + curl --location "https://api.github.com/repos/${repo}/tarball" | + tar --extract --ungzip --strip-components=1 --directory "${directory}" + ;; +*) + zz_log e "Unsupported host: {U ${host}}" + exit 1 + ;; +esac diff --git a/git-degit/test.bats b/git-degit/test.bats new file mode 100644 index 0000000..ce586da --- /dev/null +++ b/git-degit/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-degit is installed on PATH and syntactically valid" { + command -v git-degit + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-author/README.md b/git-fix-author/README.md new file mode 100644 index 0000000..56fd079 --- /dev/null +++ b/git-fix-author/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-author + +Set user.name and user.email to a specified commit's author. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-author` (invoke via `git fix-author`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-author/package.json b/git-fix-author/package.json new file mode 100644 index 0000000..6ba5f87 --- /dev/null +++ b/git-fix-author/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-author", + "version": "1.0.0", + "description": "Set user.name and user.email to a specified commit's author", + "private": true, + "bin": { + "git-fix-author": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-author/run.sh b/git-fix-author/run.sh new file mode 100755 index 0000000..ab29f2a --- /dev/null +++ b/git-fix-author/run.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Set user.name and user.email to specified commit's author" $0 "$@" <<-help + - sha sha SHA of the commit to copy author from +help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Remove global user configuration +git config --global --remove-section user 2>/dev/null || true + +# Get the commit SHA and set user config based on its author +git getcommit -f $sha | xargs -I {} sh -c 'git config user.name "$(git log -1 --pretty=format:"%an" {})"; git config user.email "$(git log -1 --pretty=format:"%ae" {})"' diff --git a/git-fix-author/test.bats b/git-fix-author/test.bats new file mode 100644 index 0000000..bb361c9 --- /dev/null +++ b/git-fix-author/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-author is installed on PATH and syntactically valid" { + command -v git-fix-author + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-base/README.md b/git-fix-base/README.md new file mode 100644 index 0000000..eba5d7b --- /dev/null +++ b/git-fix-base/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-base + +Rebase commits from one branch onto another. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-base` (invoke via `git fix-base`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-base/package.json b/git-fix-base/package.json new file mode 100644 index 0000000..edf6ead --- /dev/null +++ b/git-fix-base/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-base", + "version": "1.0.0", + "description": "Rebase commits from one branch onto another", + "private": true, + "bin": { + "git-fix-base": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-base/run.sh b/git-fix-base/run.sh new file mode 100755 index 0000000..d4cee8a --- /dev/null +++ b/git-fix-base/run.sh @@ -0,0 +1,175 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix git base - rebase commits from one branch to another" $0 "$@" <<-help + p - push push changes to remote + n - dry-run show what would be done without making changes + - target target target branch to rebase commits onto + - source source source branch to take commits from (default: current branch) +help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Validate required arguments +if [ -z "$target" ]; then + zz_log e 'Target branch is required. Use -h for help.' + exit 1 +elif ! git rev-parse --verify "$target" >/dev/null 2>&1; then + zz_log e "Target branch '$target' does not exist" + exit 1 +fi + +# Get current branch if source is not specified and validate source branch exists +if [ -z "$source" ]; then + source=$(git rev-parse --abbrev-ref HEAD) + zz_log i "Using current branch as source: $source" +elif ! git rev-parse --verify "$source" >/dev/null 2>&1; then + zz_log e "Source branch '$source' does not exist" + exit 1 +fi + +# Validate that source and target are different +if [ "$source" = "$target" ]; then + zz_log e "Source and target branches cannot be the same: $source" + exit 1 +fi + +# Check if there are uncommitted changes +if git isDirty; then + zz_log e 'Working directory is not clean. Please commit or stash your changes.' + exit 1 +fi + +# Find the merge base between source and target +base=$(git merge-base "$source" "$target") +if [ -z "$base" ]; then + zz_log e "Could not find common ancestor between '$source' and '$target'" + exit 1 +else + zz_log i "Found merge base: $base" +fi + +# Get commits that are not pushed on the source branch +commits=$(git log --reverse --format=%H "$source" --not origin/"$source" --not "$target" --no-merges) + +if [ -z "$commits" ]; then + zz_log i "No commits to move from '$source' to '$target'" + exit 0 +fi + +# Count commits +count=$(echo "$commits" | wc -l) +zz_log i "Found $count commit(s) to move from '$source' to '$target'" + +# Show commits in dry-run mode +if [ -n "$dryrun" ]; then + zz_log i "Commits that would be moved:" + echo "$commits" | while read commit; do + if [ -n "$commit" ]; then + echo " $(git log --oneline -1 "$commit")" + fi + done + zz_log i "To execute, run without -n flag" + exit 0 +fi + +# Confirm the operation +zz_log i "About to move $count commit(s) from '$source' to '$target':" +echo "$commits" | while read commit; do + if [ -n "$commit" ]; then + zz_log - " $(git log --oneline -1 "$commit")" >&2 + fi +done +zz_log i "This will:" +zz_log - " 1. Checkout '$target' branch" +zz_log - " 2. Create a temporary branch for the rebase" +zz_log - " 3. Cherry-pick commits from '$source'" +zz_log - " 4. Reset '$source' to the merge base" +zz_log - " 5. Fast-forward '$target' to include the rebased commits" +echo "" +if ! zz_ask "Yn" "Continue?"; then + zz_log e "Operation cancelled" + exit 1 +fi + +# Store current branch +current=$(git rev-parse --abbrev-ref HEAD) + +# Create a temporary branch name +temp="temp-fix-base-$(date +%s)" + +# Checkout target branch +zz_log i "Checking out '$target' branch" +if ! git checkout "$target"; then + zz_log e "Failed to checkout '$target' branch" + exit 1 +fi + +# Create temporary branch from target +zz_log i "Creating temporary branch '$temp'" +if ! git checkout -b "$temp"; then + zz_log e "Failed to create temporary branch" + git checkout "$current" 2>/dev/null + exit 1 +fi + +# Cherry-pick commits from source +zz_log i "Cherry-picking commits..." +# NB: iterate in the current shell (not a `... | while` subshell) so a failure +# propagates and we bail out before the destructive reset of the source branch. +failed=0 +for commit in $commits; do + if ! git cherry-pick "$commit" --strategy=recursive -X theirs --allow-empty; then + zz_log e "Cherry-pick failed on commit $commit" + failed=1 + break + fi +done + +# Check if cherry-pick succeeded +if [ $failed -eq 1 ]; then + zz_log e "Please resolve conflicts and run 'git cherry-pick --continue'" + zz_log e "Or run 'git cherry-pick --abort' to cancel" + exit 1 +fi + +# Fast-forward target branch +zz_log i "Fast-forwarding '$target' branch" +if ! git checkout "$target"; then + zz_log e "Failed to checkout '$target' branch" + exit 1 +fi + +if ! git merge --ff-only "$temp"; then + zz_log e "Failed to fast-forward '$target' branch" + exit 1 +elif [ -n "$push" ] && ! git push origin "$target"; then + zz_log e "Failed to push '$target' branch to remote" + exit 1 +else + zz_log i "Cleaning up temporary branch" + git branch -D "$temp" +fi + +# Reset source branch to source base +zz_log i "Resetting '$source' to source base" +if ! git checkout "$source"; then + zz_log e "Failed to checkout '$source' branch" + exit 1 +elif ! git reset --hard "$base"; then + zz_log e "Failed to reset '$source' branch" + exit 1 +fi + +# Go back to original branch +zz_log i "Switching back to original branch '$current'" +if ! git checkout "$current"; then + zz_log e "Failed to checkout original branch '$current'" + exit 1 +fi + +# Log success message +zz_log i "Successfully moved commits from '$source' to '$target'" diff --git a/git-fix-base/test.bats b/git-fix-base/test.bats new file mode 100644 index 0000000..9ae15e3 --- /dev/null +++ b/git-fix-base/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-base is installed on PATH and syntactically valid" { + command -v git-fix-base + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-blanks/README.md b/git-fix-blanks/README.md new file mode 100644 index 0000000..e62665b --- /dev/null +++ b/git-fix-blanks/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-blanks + +Discard changes made only of whitespace, blanks, quote/slash swaps. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-blanks` (invoke via `git fix-blanks`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-blanks/package.json b/git-fix-blanks/package.json new file mode 100644 index 0000000..e8798be --- /dev/null +++ b/git-fix-blanks/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-blanks", + "version": "1.0.0", + "description": "Discard changes made only of whitespace, blanks, quote/slash swaps", + "private": true, + "bin": { + "git-fix-blanks": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-blanks/run.sh b/git-fix-blanks/run.sh new file mode 100755 index 0000000..b2fe1cc --- /dev/null +++ b/git-fix-blanks/run.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# Handle parameters +eval $( + zz_args "Discard changes made only of whitespace, blanks, quote/slash swaps" $0 "$@" <<-help + d - dryrun show files that would be discarded without changing them + help +) + +# Navigate to repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null || exit 1 + +# Temp workspace for normalized comparisons +temp_dir=$(mktemp -d) +changed_list="$temp_dir/changed-files.list" +trap 'rm -rf "$temp_dir"' EXIT + +# Compare all tracked modified files from HEAD to working tree/index +git diff --name-only --diff-filter=M HEAD -- >"$changed_list" + +if [ ! -s "$changed_list" ]; then + zz_log i "No modified tracked files found." + exit 0 +fi + +normalize_file() { + # Strip full-line comments first so line-oriented filters still work, then + # collapse everything the tool is meant to ignore: all whitespace, quote + # style ("/'), slash direction (\ vs /), and blank lines. The trailing awk + # guarantees every emitted line ends in a newline, so a change that only + # adds or removes the final newline normalizes away too. + case "$1" in + *.sh) sed -e '/^[[:space:]]*#/d' "$1" ;; # Remove comment lines in shell scripts + *.yml|*.yaml) sed -e '/^[[:space:]]*#/d' "$1" ;; # Remove comment lines in YAML files + *.md|*.markdown) sed -e '/^[[:space:]]*<!--.*-->/d' "$1" ;; # Remove HTML comments in Markdown files + *.php) sed -e '/^[[:space:]]*\/\//d' -e '/^[[:space:]]*\/\*/d' -e '/^[[:space:]]*\*/d' "$1" ;; # Remove comment lines in PHP files + *.html|*.htm) sed -e '/^[[:space:]]*<!--.*-->/d' "$1" ;; # Remove HTML comments in HTML files + *.css) sed -e '/^[[:space:]]*\/\*/d' -e '/^[[:space:]]*\*/d' "$1" ;; # Remove comment lines in CSS files + *.js) sed -e '/^[[:space:]]*\/\//d' -e '/^[[:space:]]*\/\*/d' -e '/^[[:space:]]*\*/d' "$1" ;; # Remove comment lines in JavaScript files + *.json) normalize-json -c -a -i -t 4 -f local -l true "$1" 2>/dev/null || cat "$1" ;; # Normalize JSON to stdout; fall back to raw content if normalization fails (never emit empty, which would be a false match) + *) cat "$1" ;; + esac | sed -e 's/[[:space:]]//g' -e "s/[\"']/\"/g" -e 's#[\\/]#/#g' -e '/^$/d' | awk '{ print }' # strip whitespace, unify quotes/slashes, drop blank lines, force trailing newline +} + +discarded=0 +kept=0 +skipped=0 + +while IFS= read -r file; do + # Ensure the file still exists in the working tree. + if [ ! -f "$file" ]; then + skipped=$((skipped + 1)) + continue + fi + + extension="${file##*.}" + old_file="$temp_dir/old-$discarded-$kept-$skipped.$extension" + new_file="$temp_dir/new-$discarded-$kept-$skipped.$extension" + norm_old="$temp_dir/norm-old-$discarded-$kept-$skipped.$extension" + norm_new="$temp_dir/norm-new-$discarded-$kept-$skipped.$extension" + + if ! git show "HEAD:$file" >"$old_file" 2>/dev/null; then + skipped=$((skipped + 1)) + continue + fi + + cp "$file" "$new_file" + + # Ignore non-text files. + if ! grep -Iq . "$old_file" || ! grep -Iq . "$new_file"; then + skipped=$((skipped + 1)) + continue + fi + + normalize_file "$old_file" >"$norm_old" + normalize_file "$new_file" >"$norm_new" + + if cmp -s "$norm_old" "$norm_new"; then + discarded=$((discarded + 1)) + zz_log i "Discarding ignorable-only changes in $file" + if [ -z "$dryrun" ]; then + git checkout HEAD -- "$file" + fi + else + kept=$((kept + 1)) + fi +done <"$changed_list" + +if [ -n "$dryrun" ]; then + zz_log s "Dry run complete. Discardable files: $discarded, kept: $kept, skipped: $skipped" +else + zz_log s "Done. Discarded: $discarded, kept: $kept, skipped: $skipped" +fi diff --git a/git-fix-blanks/test.bats b/git-fix-blanks/test.bats new file mode 100644 index 0000000..c876e35 --- /dev/null +++ b/git-fix-blanks/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-blanks is installed on PATH and syntactically valid" { + command -v git-fix-blanks + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-children/README.md b/git-fix-children/README.md new file mode 100644 index 0000000..31e91bd --- /dev/null +++ b/git-fix-children/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-children + +Delete all descendant tags and branches of a commit. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-children` (invoke via `git fix-children`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-children/package.json b/git-fix-children/package.json new file mode 100644 index 0000000..536aa7c --- /dev/null +++ b/git-fix-children/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-children", + "version": "1.0.0", + "description": "Delete all descendant tags and branches of a commit", + "private": true, + "bin": { + "git-fix-children": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-children/run.sh b/git-fix-children/run.sh new file mode 100755 index 0000000..e466d08 --- /dev/null +++ b/git-fix-children/run.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +set -e + +# Function to print help and manage arguments +eval $( + zz_args "Delete all descendant tags and branches of a commit" $0 "$@" <<-help + p - push push to remote + f - force allow overwriting pushed history + - sha sha sha commit to start from + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Retrieve the commit SHA to fixup +sha=$(git getcommit $force $sha) + +zz_log i "Deleting all tags that are descendants of $sha" + +tags=$(git tag --contains "$sha") +if [ -n "$tags" ]; then + echo "$tags" | xargs -r git tag -d +else + zz_log w "No descendant tags found" +fi + +zz_log i "Deleting all local branches that are descendants of $sha (except current, main, master)" + +current=$(git rev-parse --abbrev-ref HEAD) +branches=$(git branch --contains "$sha" | sed "s/^[* ]*//" | grep -vE "^($current|main|master)$") +if [ -n "$branches" ]; then + echo "$branches" | xargs -r git branch -D +else + zz_log w "No descendant branches found" +fi + +if [ -n "$push" ]; then + + zz_log i "Pushing deletions to remote" + for tag in $tags; do + git push origin ":refs/tags/$tag" || zz_log w "Failed to delete remote tag $tag" + done + for branch in $branches; do + git push origin --delete "$branch" || zz_log w "Failed to delete remote branch $branch" + done +else + zz_log w "Remote deletions not pushed. Use -p to push deletions to remote." +fi diff --git a/git-fix-children/test.bats b/git-fix-children/test.bats new file mode 100644 index 0000000..f1f9dfd --- /dev/null +++ b/git-fix-children/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-children is installed on PATH and syntactically valid" { + command -v git-fix-children + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-date/README.md b/git-fix-date/README.md new file mode 100644 index 0000000..092b6d8 --- /dev/null +++ b/git-fix-date/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-date + +Fix commit dates and times in git history. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-date` (invoke via `git fix-date`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-date/package.json b/git-fix-date/package.json new file mode 100644 index 0000000..0c7576f --- /dev/null +++ b/git-fix-date/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-date", + "version": "1.0.0", + "description": "Fix commit dates and times in git history", + "private": true, + "bin": { + "git-fix-date": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-date/run.sh b/git-fix-date/run.sh new file mode 100755 index 0000000..66149a3 --- /dev/null +++ b/git-fix-date/run.sh @@ -0,0 +1,213 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix commit dates and times in git history" $0 "$@" <<-help + f - force allow overwriting pushed history + p - push push changes after rewriting history + d - dryrun dry-run mode: output change plan without applying changes + r days days days of week to reschedule (default: 1,2,3,4,5 for Mon-Fri; 0=Sunday, 6=Saturday) + s start start start time for rescheduling (default: 08:00, HH:MM format) + e end end end time for rescheduling (default: 17:00, HH:MM format) + b before before time to move first half commits to (default: 06:00, HH:MM format) + a after after time to move second half commits to (default: 20:00, HH:MM format) + - sha sha sha commit to fix from + help +) + +# Set default values +days="${days:-1,2,3,4,5}" +start="${start:-08:00}" +end="${end:-17:00}" +before="${before:-06:00}" +after="${after:-20:00}" + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Make sure we don't have uncommitted changes +if ! git diff-index --quiet HEAD --; then + zz_log e "You have uncommitted changes. Please commit or stash them before running this script." + exit 1 +fi + +# Retrieve the commit SHA to fix from +sha=$(git getcommit $force $sha) + +# Validate time formats + +# Prevent running while a rebase is in progress +if git isRebase >/dev/null 2>&1; then + zz_log e "A rebase is in progress. Please finish or abort it before running this script." + exit 1 +fi +if ! echo "$start" | grep -qE '^[0-9]{2}:[0-9]{2}$'; then + zz_log e "Invalid start time format. Use HH:MM (e.g., 08:00)" + exit 1 +fi +if ! echo "$end" | grep -qE '^[0-9]{2}:[0-9]{2}$'; then + zz_log e "Invalid end time format. Use HH:MM (e.g., 17:00)" + exit 1 +fi +if ! echo "$before" | grep -qE '^[0-9]{2}:[0-9]{2}$'; then + zz_log e "Invalid before time format. Use HH:MM (e.g., 06:00)" + exit 1 +fi +if ! echo "$after" | grep -qE '^[0-9]{2}:[0-9]{2}$'; then + zz_log e "Invalid after time format. Use HH:MM (e.g., 20:00)" + exit 1 +fi + +# Convert days to array for bash processing +days_array=$(echo "$days" | tr ',' ' ') + +zz_log s "Rescheduling commits on days: $days (0=Sunday, 6=Saturday)" +zz_log s "Time range: $start - $end" +zz_log s "First half ($start - midpoint) will move to before $before" +zz_log s "Second half (midpoint - $end) will move to after $after" + +#### Rewrite history to fix dates +if [ -n "$dryrun" ]; then + zz_log i "DRY RUN MODE: No changes will be applied" +else + zz_log i "Rewriting commit dates from commit $sha" +fi + +# Rescheduling mode - need to process commits in two passes +# First pass: collect commits that need rescheduling +# Second pass: redistribute them while maintaining sequential order + +zz_log i "Analyzing commits for rescheduling..." + +# Get list of commits to process +if [ -n "$sha" ]; then + commit_range="${sha}..HEAD" +else + commit_range="--all" +fi + +# Create a temporary mapping file for new times +temp_map=$(mktemp) +trap "rm -f $temp_map" EXIT + +# Collect commits that need rescheduling +git log --format="%H|%ai|%ci|%s" --reverse $commit_range | while IFS='|' read commit_sha author_date committer_date subject; do + # Extract date and time components + a_date=$(echo "$author_date" | cut -d' ' -f1) + a_time=$(echo "$author_date" | cut -d' ' -f2) + a_tz=$(echo "$author_date" | cut -d' ' -f3-) + + c_date=$(echo "$committer_date" | cut -d' ' -f1) + c_time=$(echo "$committer_date" | cut -d' ' -f2) + c_tz=$(echo "$committer_date" | cut -d' ' -f3-) + + # Get day of week for author date + dow=$(date -d "$a_date" +%w 2>/dev/null || echo "") + + # Check if this day should be rescheduled + should_reschedule=0 + for day in $(echo "$days" | tr ',' ' '); do + if [ "$dow" = "$day" ]; then + should_reschedule=1 + break + fi + done + + if [ "$should_reschedule" = "0" ]; then + # No rescheduling needed + echo "$commit_sha|$author_date|$committer_date" >> "$temp_map" + continue + fi + + # Convert time to seconds + a_seconds=$(echo "$a_time" | awk -F: '{print ($1 * 3600) + ($2 * 60) + $3}') + start_seconds=$(echo "$start" | awk -F: '{print ($1 * 3600) + ($2 * 60)}') + end_seconds=$(echo "$end" | awk -F: '{print ($1 * 3600) + ($2 * 60)}') + + # Check if time is in the range to reschedule + if [ "$a_seconds" -ge "$start_seconds" ] && [ "$a_seconds" -le "$end_seconds" ]; then + # Calculate midpoint + mid_seconds=$(( (start_seconds + end_seconds) / 2 )) + + # Determine new time based on which half of the range + if [ "$a_seconds" -lt "$mid_seconds" ]; then + # First half - move to before time + new_time="$before:00" + else + # Second half - move to after time + new_time="$after:00" + fi + + new_author_date="$a_date $new_time $a_tz" + new_committer_date="$c_date $new_time $c_tz" + + # Always output change plan + zz_log - "$(echo "$commit_sha" | cut -c1-7) | $a_date $a_time → $a_date $new_time | $subject" + + echo "$commit_sha|$new_author_date|$new_committer_date" >> "$temp_map" + else + # Not in range, keep as-is + echo "$commit_sha|$author_date|$committer_date" >> "$temp_map" + fi + done + + +# Exit if dry-run mode +if [ -n "$dryrun" ]; then + zz_log i "Dry run complete. No changes were made." + exit 0 +fi + + +# Ask for confirmation before proceeding (use helper prompt) +zz_log w "This will rewrite git history. Make sure you understand the consequences." +if ! zz_ask "Yn" "Do you want to proceed?"; then + zz_log i "Operation cancelled by user." + exit 1 +fi + +# Export the mapping file path for the filter +export DATE_MAP_FILE="$temp_map" + +date_filter=' + # Look up the new date for this commit + commit_sha="$GIT_COMMIT" + map_file="$DATE_MAP_FILE" + + if [ -f "$map_file" ]; then + line=$(grep "^$commit_sha|" "$map_file" || echo "") + if [ -n "$line" ]; then + new_author=$(echo "$line" | cut -d"|" -f2) + new_committer=$(echo "$line" | cut -d"|" -f3) + + if [ -n "$new_author" ]; then + export GIT_AUTHOR_DATE="$new_author" + fi + if [ -n "$new_committer" ]; then + export GIT_COMMITTER_DATE="$new_committer" + fi + fi + fi +' + +# Execute filter-branch with the date filter +git filter-branch --env-filter "$date_filter" --tag-name-filter cat -- --branches --tags ${sha:---all}${sha:+..HEAD} + +# Clean up the original refs +rm -rf .git/refs/original/ +git reflog expire --expire=now --all +git gc --prune=now + +# Push changes if the push flag is set +if [ "$push" = true ]; then + if [ "$force" = true ]; then + git push --force --tags origin 'refs/heads/*' + else + git push --force-with-lease --tags origin 'refs/heads/*' + fi +fi + +zz_log s "Git date fixup completed successfully." diff --git a/git-fix-date/test.bats b/git-fix-date/test.bats new file mode 100644 index 0000000..ec1cac1 --- /dev/null +++ b/git-fix-date/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-date is installed on PATH and syntactically valid" { + command -v git-fix-date + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-del/README.md b/git-fix-del/README.md new file mode 100644 index 0000000..4ed8a16 --- /dev/null +++ b/git-fix-del/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-del + +Delete a specified commit and rebase subsequent history. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-del` (invoke via `git fix-del`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-del/package.json b/git-fix-del/package.json new file mode 100644 index 0000000..68ae2ef --- /dev/null +++ b/git-fix-del/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-del", + "version": "1.0.0", + "description": "Delete a specified commit and rebase subsequent history", + "private": true, + "bin": { + "git-fix-del": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-del/run.sh b/git-fix-del/run.sh new file mode 100755 index 0000000..2a58dc4 --- /dev/null +++ b/git-fix-del/run.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +# Handle parameters +eval $( + zz_args "Delete a specified commit and rebase subsequent history" $0 "$@" <<-help + f - force allow overwriting pushed history + p - push push to remote after deletion + a - auto fully automatic mode - no prompts + s strategy strategy strategy to use for conflict resolution (default: theirs) + - sha sha sha commit to delete +help +) + +# Set defaults and navigate to repository root +strategy="${strategy:-theirs}" +cd "$(git rev-parse --show-toplevel)" >/dev/null +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Check for ongoing rebase +if git isRebase; then + zz_log e 'Rebase is in progress, please finish or abort it first.' + exit 1 +fi + +# Get and validate commit +sha=$(git getcommit $force $sha) +if ! git rev-parse --verify "$sha^" >/dev/null 2>&1; then + zz_log e "Cannot delete commit $sha (invalid or initial commit)." + exit 1 +fi + +zz_log i "Deleting commit: $sha" + +# Attempt deletion with fallback to autorebase +if ! git autorebase ${auto:+-a} -s "$strategy" -o "$sha^" "$sha" ; then + zz_log e "Failed to delete commit $sha. Please resolve conflicts manually." + exit 1 +else + zz_log s "Commit deleted with conflict resolution" +fi + +# Push if requested +if [ -n "$push" ] && git rev-parse --verify --quiet origin/HEAD >/dev/null; then + zz_log i "Pushing to remote..." + git push ${force:+--force-with-lease} origin HEAD +elif [ -n "$push" ]; then + zz_log i "Branch not tracked remotely, skipping push" +fi + +zz_log s "Commit $sha successfully deleted and history rebased." \ No newline at end of file diff --git a/git-fix-del/test.bats b/git-fix-del/test.bats new file mode 100644 index 0000000..8a063a7 --- /dev/null +++ b/git-fix-del/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-del is installed on PATH and syntactically valid" { + command -v git-fix-del + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-emoji/README.md b/git-fix-emoji/README.md new file mode 100644 index 0000000..b0ce5fc --- /dev/null +++ b/git-fix-emoji/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-emoji + +Fix git emoji. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-emoji` (invoke via `git fix-emoji`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-emoji/package.json b/git-fix-emoji/package.json new file mode 100644 index 0000000..11f28ea --- /dev/null +++ b/git-fix-emoji/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-emoji", + "version": "1.0.0", + "description": "Fix git emoji", + "private": true, + "bin": { + "git-fix-emoji": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-emoji/run.sh b/git-fix-emoji/run.sh new file mode 100755 index 0000000..afd274b --- /dev/null +++ b/git-fix-emoji/run.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix git emoji" $0 "$@" <<-help + f - force allow overwritting pushed history + p - push push changes after rewriting history + - sha sha sha commit to fix from after + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Make sure we don't have uncommitted changes +if ! git diff-index --quiet HEAD --; then + zz_log e "You have uncommitted changes. Please commit or stash them before running this script." + exit 1 +fi + +# Retrieve the commit SHA to fixup +sha=$(git getcommit -p $force $sha) + +#### Rewrite history to fix author +# Rewrite commit messages to add the appropriate emoji from the specified commit +git filter-branch --msg-filter 'npx --yes devmoji' --tag-name-filter cat -- --branches --tags ${sha:---all}${sha:+..HEAD} + +# Clean up the original refs +rm -rf .git/refs/original/ +git reflog expire --expire=now --all +git gc --prune=now + +# Push changes if the push flag is set +if [ "$push" = true ]; then + if [ "$force" = true ]; then + git push --force --tags origin 'refs/heads/*' + else + git push --force-with-lease --tags origin 'refs/heads/*' + fi +fi + +zz_log s "Git emoji fixup completed successfully." diff --git a/git-fix-emoji/test.bats b/git-fix-emoji/test.bats new file mode 100644 index 0000000..537a34b --- /dev/null +++ b/git-fix-emoji/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-emoji is installed on PATH and syntactically valid" { + command -v git-fix-emoji + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-last/README.md b/git-fix-last/README.md new file mode 100644 index 0000000..d8a6591 --- /dev/null +++ b/git-fix-last/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-last + +Edit the last commit message and content. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-last` (invoke via `git fix-last`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-last/package.json b/git-fix-last/package.json new file mode 100644 index 0000000..a272cc4 --- /dev/null +++ b/git-fix-last/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-last", + "version": "1.0.0", + "description": "Edit the last commit message and content", + "private": true, + "bin": { + "git-fix-last": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-last/run.sh b/git-fix-last/run.sh new file mode 100755 index 0000000..ab590fa --- /dev/null +++ b/git-fix-last/run.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Edit the last commit message and content" $0 "$@" <<-help + m msg msg New commit message +help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Edit the last commit message and content +git commit --amend --edit ${msg:+-m "$msg"} diff --git a/git-fix-last/test.bats b/git-fix-last/test.bats new file mode 100644 index 0000000..bcfab6e --- /dev/null +++ b/git-fix-last/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-last is installed on PATH and syntactically valid" { + command -v git-fix-last + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-lock/README.md b/git-fix-lock/README.md new file mode 100644 index 0000000..4e8f460 --- /dev/null +++ b/git-fix-lock/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-lock + +Fix git lock files - resolve conflicts and regenerate lock files. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-lock` (invoke via `git fix-lock`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-lock/package.json b/git-fix-lock/package.json new file mode 100644 index 0000000..ce1a311 --- /dev/null +++ b/git-fix-lock/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-lock", + "version": "1.0.0", + "description": "Fix git lock files - resolve conflicts and regenerate lock files", + "private": true, + "bin": { + "git-fix-lock": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-lock/run.sh b/git-fix-lock/run.sh new file mode 100755 index 0000000..d178cb9 --- /dev/null +++ b/git-fix-lock/run.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix git lock files - resolve conflicts and regenerate lock files" $0 "$@" <<-help +help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# List files with conflicts and filter only lock files +conflicted_files=$(git diff --name-only --diff-filter=U | grep -E 'composer.lock|package-lock.json|yarn.lock') + +# Process each lock file with conflicts +for file in $conflicted_files; do + + # Keep our version of the lock file, then regenerate it below + zz_log i "Fixing merge conflict in $file" + git checkout --ours "$file" + + # Regenerate the lock file based on the type of file + case "$file" in + composer.lock) + # Regenerate composer.lock file with minimal changes + zz_log i "Regenerating composer.lock..." + composer update --lock --minimal-changes --ignore-platform-reqs --with-all-dependencies --no-scripts --no-interaction --no-progress --no-install + ;; + package-lock.json) + # Check if the project uses npm workspaces and regenerate package-lock.json accordingly + zz_log i "Regenerating package-lock.json..." + ws=$(npm pkg get workspaces) + if test "$ws" = "undefined" || test "$ws" = "{}"; then + npm install --package-lock + else + npm install --package-lock --ws --if-present --include-workspace-root + fi + ;; + yarn.lock) + # Regenerate yarn.lock file + zz_log i "Regenerating yarn.lock..." + yarn install --check-files + ;; + esac + # Stage the updated lock file for commit + zz_log i "Staging $file for commit..." + git add "$file" +done diff --git a/git-fix-lock/test.bats b/git-fix-lock/test.bats new file mode 100644 index 0000000..c58f53c --- /dev/null +++ b/git-fix-lock/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-lock is installed on PATH and syntactically valid" { + command -v git-fix-lock + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-message/README.md b/git-fix-message/README.md new file mode 100644 index 0000000..a73453e --- /dev/null +++ b/git-fix-message/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-message + +Rewrite an arbitrary commit message. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-message` (invoke via `git fix-message`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-message/package.json b/git-fix-message/package.json new file mode 100644 index 0000000..761b87e --- /dev/null +++ b/git-fix-message/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-message", + "version": "1.0.0", + "description": "Rewrite an arbitrary commit message", + "private": true, + "bin": { + "git-fix-message": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-message/run.sh b/git-fix-message/run.sh new file mode 100755 index 0000000..303690e --- /dev/null +++ b/git-fix-message/run.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Rewrite an arbitrary commit message" $0 "$@" <<-help + f - force allow overwritting pushed history + p - push push to remote + m msg msg new commit message + - sha sha sha commit to rewrite message + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Make sure we don't have uncommitted changes +if ! git diff-index --quiet HEAD --; then + zz_log e "You have uncommitted changes. Please commit or stash them before running this script." + exit 1 +fi + +# Prevent running while a rebase is in progress +if git isRebase >/dev/null 2>&1; then + zz_log e "A rebase is in progress. Please finish or abort it before running this script." + exit 1 +fi + +# Retrieve the commit SHA to edit +sha=$(git getcommit $force $sha) + +# Validate commit exists +if ! git rev-parse --verify --quiet "$sha^{commit}" >/dev/null; then + zz_log e "Invalid commit: $sha" + exit 1 +fi + +# Ensure commit belongs to current branch history +if ! git merge-base --is-ancestor "$sha" HEAD; then + zz_log e "Commit $(echo "$sha" | cut -c1-7) is not in the current branch history." + exit 1 +fi + +# Ask for the new message if not provided as argument +if [ -z "$msg" ]; then + msg=$(zz_prompt "New commit message:") +fi + +# Ensure message is not empty +if [ -z "$msg" ]; then + zz_log e "Commit message cannot be empty." + exit 1 +fi + +old_msg=$(git log -1 --pretty=%s "$sha") +zz_log w "Commit to rewrite: $(echo "$sha" | cut -c1-7)" +zz_log - "Current message: $old_msg" +zz_log - "New message: $msg" +zz_log w "This will rewrite git history. Make sure you understand the consequences." + +if ! zz_ask "Yn" "Do you want to proceed?"; then + zz_log i "Operation cancelled by user." + exit 1 +fi + +# Build a minimal range that includes target commit and descendants. +if [ -n "$(git rev-list --parents -n 1 "$sha" | cut -d' ' -f2)" ]; then + range="$sha^..HEAD" +else + range="--all" +fi + +TARGET_SHA="$sha" NEW_MESSAGE="$msg" git filter-branch --msg-filter ' + if [ "$GIT_COMMIT" = "$TARGET_SHA" ]; then + printf "%s\n" "$NEW_MESSAGE" + else + cat + fi +' --tag-name-filter cat -- $range + +# Clean up the original refs +rm -rf .git/refs/original/ +git reflog expire --expire=now --all +git gc --prune=now + +# Push rewritten history if requested +if [ -n "$push" ]; then + if git rev-parse --verify --quiet origin/HEAD >/dev/null; then + zz_log i "Pushing to remote..." + if [ -n "$force" ]; then + git push --force origin HEAD + else + git push --force-with-lease origin HEAD + fi + else + zz_log i "Branch not pushed, skipping push..." + fi +fi + +zz_log s "Commit message rewritten successfully." \ No newline at end of file diff --git a/git-fix-message/test.bats b/git-fix-message/test.bats new file mode 100644 index 0000000..39cf2ba --- /dev/null +++ b/git-fix-message/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-message is installed on PATH and syntactically valid" { + command -v git-fix-message + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-mode/README.md b/git-fix-mode/README.md new file mode 100644 index 0000000..1118d99 --- /dev/null +++ b/git-fix-mode/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-mode + +Fix file mode changes from diff. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-mode` (invoke via `git fix-mode`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-mode/package.json b/git-fix-mode/package.json new file mode 100644 index 0000000..40ccb4e --- /dev/null +++ b/git-fix-mode/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-mode", + "version": "1.0.0", + "description": "Fix file mode changes from diff", + "private": true, + "bin": { + "git-fix-mode": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-mode/run.sh b/git-fix-mode/run.sh new file mode 100755 index 0000000..a79be84 --- /dev/null +++ b/git-fix-mode/run.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Fix file mode changes from diff +# Handle the case where no deleted files exist + +# Get the list of deleted files +deleted_files=$(git ls-files --deleted) + +# Generate the diff with mode information +diff_output=$(git diff -p -R --no-color | grep -E "^(diff|(old|new) mode)" --color=never) + +# If we have diff output +if [ -n "$diff_output" ]; then + if [ -n "$deleted_files" ]; then + # Filter out deleted files + echo "$diff_output" | grep -vF "$deleted_files" | git apply --allow-empty --no-index + else + # No deleted files, apply all mode changes + echo "$diff_output" | git apply --allow-empty --no-index + fi +fi + +exit 0 diff --git a/git-fix-mode/test.bats b/git-fix-mode/test.bats new file mode 100644 index 0000000..d001ff9 --- /dev/null +++ b/git-fix-mode/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-mode is installed on PATH and syntactically valid" { + command -v git-fix-mode + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-privacy/README.md b/git-fix-privacy/README.md new file mode 100644 index 0000000..30acbff --- /dev/null +++ b/git-fix-privacy/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-privacy + +Fix privacy in history. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-privacy` (invoke via `git fix-privacy`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-privacy/package.json b/git-fix-privacy/package.json new file mode 100644 index 0000000..b4186a8 --- /dev/null +++ b/git-fix-privacy/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-privacy", + "version": "1.0.0", + "description": "Fix privacy in history", + "private": true, + "bin": { + "git-fix-privacy": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-privacy/run.sh b/git-fix-privacy/run.sh new file mode 100755 index 0000000..23d26a1 --- /dev/null +++ b/git-fix-privacy/run.sh @@ -0,0 +1,86 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix privacy in history" $0 "$@" <<-help + f - force force overwrite backup + p - push push to remote + o old old old email to replace + n new new new name to replace with + a author author author name to replace with + - sha sha sha commit to start from + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Check if the old option is set +if [ -z "$old" ]; then + zz_log w "Old email is not specified, it will be taken from the last commit" + old=$(git log -1 --pretty=format:'%ae') + + # Asks for confirmation to proceed with the old email + zz_ask Yn "Do you want to proceed with this <$old> as old email?" || exit 1 +fi + +# Check if the new option is set +if [ -z "$new" ]; then + zz_log w "New email is not specified, it will be taken from the specified commit" +fi + +# Check if the author option is set +if [ -z "$author" ]; then + zz_log w "Author is not specified, it will be taken from the specified commit" +fi + +# Retrieve the commit SHA to fixup +sha=$(git getcommit $force $sha) + +# Log the commit SHA to be fixed up +zz_log i "Fix privacy from commit: $sha" + +if [ -n "$new" ]; then + zz_log i "Setting new email for git config" + git config user.email "$new" +else + zz_log s "Take email from specified commit" + new=$(git log -1 --pretty=format:'%ae' "$sha") +fi + +if [ -n "$author" ]; then + echo "Setting new author for git config" + git config user.name "$author" +else + zz_log s "Take author from specified commit" + author=$(git log -1 --pretty=format:'%an' "$sha") +fi + +git filter-branch $force --env-filter " +if [ \"\$GIT_COMMITTER_EMAIL\" = \"$old\" ] +then + GIT_COMMITTER_NAME=\"$author\" + GIT_COMMITTER_EMAIL=\"$new\" +fi +if [ \"\$GIT_AUTHOR_EMAIL\" = \"$old\" ] +then + GIT_AUTHOR_NAME=\"$author\" + GIT_AUTHOR_EMAIL=\"$new\" +fi +" --tag-name-filter cat -- --branches --tags ${sha:---all}${sha:+..HEAD} + +if [ -n "$push" ]; then + zz_log i "Pushing changes to remote" + git push --force --progress --recurse-submodules=no origin --all + git push --force --progress --recurse-submodules=no origin --tags +else + zz_log w "Changes are not pushed to remote, use -p option to push" +fi + +# Clean up the original refs +rm -rf .git/refs/original/ +git reflog expire --expire=now --all +git gc --prune=now diff --git a/git-fix-privacy/test.bats b/git-fix-privacy/test.bats new file mode 100644 index 0000000..6a52e70 --- /dev/null +++ b/git-fix-privacy/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-privacy is installed on PATH and syntactically valid" { + command -v git-fix-privacy + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-prune/README.md b/git-fix-prune/README.md new file mode 100644 index 0000000..f149114 --- /dev/null +++ b/git-fix-prune/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-prune + +Prune remote-tracking references that no longer exist on the remote. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-prune` (invoke via `git fix-prune`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-prune/package.json b/git-fix-prune/package.json new file mode 100644 index 0000000..7f11d03 --- /dev/null +++ b/git-fix-prune/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-prune", + "version": "1.0.0", + "description": "Prune remote-tracking references that no longer exist on the remote", + "private": true, + "bin": { + "git-fix-prune": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-prune/run.sh b/git-fix-prune/run.sh new file mode 100755 index 0000000..0aa438c --- /dev/null +++ b/git-fix-prune/run.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Prune remote-tracking references that no longer exist on the remote" $0 "$@" <<-help + - remote remote remote to prune (default: all remotes) + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +remotes="${remote:-$(git remote)}" + +for r in $remotes; do + zz_log i "Pruning stale remote-tracking references for $r" + git remote prune "$r" +done + +zz_log s "Remote-tracking references pruned." diff --git a/git-fix-prune/test.bats b/git-fix-prune/test.bats new file mode 100644 index 0000000..ce4addc --- /dev/null +++ b/git-fix-prune/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-prune is installed on PATH and syntactically valid" { + command -v git-fix-prune + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-rights/README.md b/git-fix-rights/README.md new file mode 100644 index 0000000..c4d9c3c --- /dev/null +++ b/git-fix-rights/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-rights + +Set appropriate permissions for files and directories. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-rights` (invoke via `git fix-rights`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-rights/package.json b/git-fix-rights/package.json new file mode 100644 index 0000000..efa68cf --- /dev/null +++ b/git-fix-rights/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-rights", + "version": "1.0.0", + "description": "Set appropriate permissions for files and directories", + "private": true, + "bin": { + "git-fix-rights": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-rights/run.sh b/git-fix-rights/run.sh new file mode 100755 index 0000000..72057bd --- /dev/null +++ b/git-fix-rights/run.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix git access rights - set appropriate permissions for files and directories" $0 "$@" <<-help + + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Apply the given permission to the tracked files matching the given pathspecs. +# Operates on files tracked by git (not ignored/untracked ones), matching the +# stated intent of normalising the repository's own file permissions. +set_permissions() { + perm="$1" + shift + zz_log i "Setting permissions $perm for: ${*:-<all tracked files>}" + git ls-files -z "$@" | xargs -0 -r chmod "$perm" +} + +# Regular files default to 644, scripts to 755, sensitive files to 600 +set_permissions 644 +set_permissions 755 '*.sh' +set_permissions 600 '*.conf' '*.env' + +# Directories default to 755 (git does not track directories, so derive them +# from the working tree, excluding the .git internals) +find "." -type d -not -path '*/.git' -not -path '*/.git/*' -exec chmod 755 {} + + +# Tighten well-known sensitive directories (e.g. logs, cache) to 700 +find "." -type d \( -name logs -o -name cache \) -not -path '*/.git/*' -exec chmod 700 {} + + +zz_log s "Access rights have been set according to best practices." diff --git a/git-fix-rights/test.bats b/git-fix-rights/test.bats new file mode 100644 index 0000000..2e74f8e --- /dev/null +++ b/git-fix-rights/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-rights is installed on PATH and syntactically valid" { + command -v git-fix-rights + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-secrets/README.md b/git-fix-secrets/README.md new file mode 100644 index 0000000..db0df28 --- /dev/null +++ b/git-fix-secrets/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-secrets + +Redact a secret from files, commit messages and/or tag annotations across all git history. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-secrets` (invoke via `git fix-secrets`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-secrets/package.json b/git-fix-secrets/package.json new file mode 100644 index 0000000..b3e1af8 --- /dev/null +++ b/git-fix-secrets/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-secrets", + "version": "1.0.0", + "description": "Redact a secret from files, commit messages and/or tag annotations across all git history", + "private": true, + "bin": { + "git-fix-secrets": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-secrets/run.sh b/git-fix-secrets/run.sh new file mode 100755 index 0000000..87bb0c6 --- /dev/null +++ b/git-fix-secrets/run.sh @@ -0,0 +1,176 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Redact a secret from files, commit messages and/or tag annotations across all git history" $0 "$@" <<-help + f - force allow overwriting pushed history + p - push push to remote + d - dryrun list matching commits/files without rewriting history + g glob glob glob pattern of files to search (e.g. "**/*.env") + s secret secret secret value to redact + r repl replace replacement string (default: ****) + m - fixmsg also redact the secret from commit messages + t - fixtags also redact the secret from tag annotation messages + - sha sha sha commit to fix from (use 0 for the very first commit) + help +) + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Check if the glob option is set +if [ -z "$glob" ]; then + glob=$(zz_prompt "Glob pattern of files to search (e.g. **/*.env):") +fi + +if [ -z "$glob" ]; then + zz_log e "A glob pattern is required." + exit 1 +fi + +# Check if the secret option is set +if [ -z "$secret" ]; then + secret=$(zz_prompt "Secret value to redact:") +fi + +if [ -z "$secret" ]; then + zz_log e "A secret value is required." + exit 1 +fi + +# Default replacement string +replace="${replace:-****}" + +# Make sure we don't have uncommitted changes +if ! git diff-index --quiet HEAD --; then + zz_log e "You have uncommitted changes. Please commit or stash them before running this script." + exit 1 +fi + +# Prevent running while a rebase is in progress +if git isRebase >/dev/null 2>&1; then + zz_log e "A rebase is in progress. Please finish or abort it before running this script." + exit 1 +fi + +# Retrieve the commit SHA to fix from +sha=$(git getcommit $force $sha) + +zz_log i "Searching for secret in files matching '$glob'" + +file_matches=$(git grep -I -l -F "$secret" $(git rev-list --branches --tags ${sha:---all}${sha:+..HEAD}) -- "$glob" 2>/dev/null) + +msg_matches="" +if [ -n "$fixmsg" ]; then + msg_matches=$(git log --branches --tags ${sha:---all}${sha:+..HEAD} -F --grep="$secret" --oneline) +fi + +tag_matches="" +if [ -n "$fixtags" ]; then + for t in $(git tag -l); do + msg=$(git for-each-ref --format='%(contents)' "refs/tags/$t" 2>/dev/null) + if printf '%s' "$msg" | grep -qF "$secret"; then + tag_matches="$tag_matches$t +" + fi + done +fi + +if [ -z "$file_matches" ] && [ -z "$msg_matches" ] && [ -z "$tag_matches" ]; then + zz_log s "No occurrences of the secret found." + exit 0 +fi + +[ -n "$file_matches" ] && zz_log - "Files:" && zz_log - "$file_matches" +[ -n "$msg_matches" ] && zz_log - "Commit messages:" && zz_log - "$msg_matches" +[ -n "$tag_matches" ] && zz_log - "Tag annotations:" && zz_log - "$tag_matches" + +if [ -n "$dryrun" ]; then + zz_log i "Dry run complete. No changes were made." + exit 0 +fi + +zz_log w "This will rewrite git history. Make sure you understand the consequences." +if ! zz_ask "Yn" "Do you want to proceed?"; then + zz_log i "Operation cancelled by user." + exit 1 +fi + +# Escape the secret so it is matched literally by sed, not as a regex +sed_escape_pattern() { + printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' \ + -e 's/\//\\\//g' \ + -e 's/\./\\./g' \ + -e 's/\*/\\*/g' \ + -e 's/\[/\\[/g' \ + -e 's/\^/\\^/g' \ + -e 's/\$/\\$/g' +} + +# Escape the replacement so it is inserted literally by sed +sed_escape_replacement() { + printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' \ + -e 's/\//\\\//g' \ + -e 's/&/\\&/g' +} + +esc_secret=$(sed_escape_pattern "$secret") +esc_replace=$(sed_escape_replacement "$replace") +export SED_EXPR="s/${esc_secret}/${esc_replace}/g" +export GLOB_PATTERN="$glob" + +tree_filter=' + git ls-files -- "$GLOB_PATTERN" | while IFS= read -r file; do + [ -f "$file" ] || continue + grep -Iq . "$file" 2>/dev/null || continue + sed -i "$SED_EXPR" "$file" + done +' + +if [ -n "$fixmsg" ]; then + msg_filter='sed "$SED_EXPR"' +else + msg_filter='cat' +fi + +git filter-branch $force --tree-filter "$tree_filter" --msg-filter "$msg_filter" --tag-name-filter cat -- --branches --tags ${sha:---all}${sha:+..HEAD} + +# Clean up the original refs +rm -rf .git/refs/original/ +git reflog expire --expire=now --all +git gc --prune=now + +# Redact secret from annotated tag messages (filter-branch does not rewrite tag content) +if [ -n "$fixtags" ]; then + zz_log i "Redacting secret from tag annotations" + for t in $(git tag -l); do + obj_type=$(git cat-file -t "refs/tags/$t" 2>/dev/null) + if [ "$obj_type" = "tag" ]; then + msg=$(git for-each-ref --format='%(contents)' "refs/tags/$t") + if printf '%s' "$msg" | grep -qF "$secret"; then + new_msg=$(printf '%s' "$msg" | sed "$SED_EXPR") + target=$(git rev-list -n 1 "$t") + tagger_name=$(git for-each-ref --format='%(taggername)' "refs/tags/$t") + tagger_email=$(git for-each-ref --format='%(taggeremail)' "refs/tags/$t" | sed -e 's/^<//' -e 's/>$//') + tagger_date=$(git for-each-ref --format='%(taggerdate:iso-strict)' "refs/tags/$t") + GIT_COMMITTER_NAME="$tagger_name" GIT_COMMITTER_EMAIL="$tagger_email" GIT_COMMITTER_DATE="$tagger_date" \ + git tag -f -a "$t" "$target" -m "$new_msg" + fi + fi + done +fi + +if [ -n "$push" ]; then + zz_log i "Pushing changes to remote" + git push --force --progress --recurse-submodules=no origin --all + git push --force --progress --recurse-submodules=no origin --tags +else + zz_log w "Changes are not pushed to remote, use -p option to push" +fi + +zz_log s "Secret replaced with '$replace' in history." diff --git a/git-fix-secrets/test.bats b/git-fix-secrets/test.bats new file mode 100644 index 0000000..dae6f2c --- /dev/null +++ b/git-fix-secrets/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-secrets is installed on PATH and syntactically valid" { + command -v git-fix-secrets + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix-up/README.md b/git-fix-up/README.md new file mode 100644 index 0000000..99a4a2c --- /dev/null +++ b/git-fix-up/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix-up + +Amend the specified commit with current changes and rebase. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix-up` (invoke via `git fix-up`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix-up/package.json b/git-fix-up/package.json new file mode 100644 index 0000000..c638c8d --- /dev/null +++ b/git-fix-up/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix-up", + "version": "1.0.0", + "description": "Amend the specified commit with current changes and rebase", + "private": true, + "bin": { + "git-fix-up": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix-up/run.sh b/git-fix-up/run.sh new file mode 100755 index 0000000..39270a3 --- /dev/null +++ b/git-fix-up/run.sh @@ -0,0 +1,94 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Fix git history" $0 "$@" <<-help + f - force allow overwritting pushed history + e - edit edit commit message + p - push push to remote + - sha sha sha commit to fixup + help +) + +# Do not fixup if staged files contain composer.lock or package-lock.json +if [ -n "$(git diff --cached --name-only | grep -E 'composer.lock|package-lock.json')" ]; then + zz_log e 'Packages lock file are staged, fixup is not allowed.' + exit 1 +fi + +# Prepare environment variables GIT_EDITOR based on edit option +if [ -z "$edit" ]; then + export GIT_EDITOR=":" +fi + +# Navigate to the repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +# Check if a fixup commit already exists +if git isFixup; then + zz_log e 'Fixup commit found, please continue rebasing...' + exit 1 +fi + +# Ensure there are staged files before proceeding +if [ -z "$(git diff --cached --name-only)" ]; then + zz_log e 'No files are staged, fixup is not allowed.' + exit 1 +fi + +# Retrieve the commit SHA to fixup +sha=$(git getcommit $force $sha) + +# Log the commit SHA to be fixed up +zz_log i "Fixup commit given: $sha" + +# Create a fixup commit and handle failure +if ! git commit --fixup $sha; then + zz_log e 'Fixup commit failed...' + exit 1 +fi + +# Start an interactive rebase with autosquash +git rebase -i --autosquash $sha~ --autostash --no-verify --reschedule-failed-exec --exec 'git hook run --ignore-missing pre-commit -- HEAD HEAD~1 && git commit --amend --no-edit --no-verify' --no-verify + +while [ $? -ne 0 ]; do + + # Check if there are any composer conflicts during the rebase + if [ -f composer.lock ]; then + if grep -q "^<<<<<<< \|^======= \|^>>>>>>> " composer.lock; then + # accept incoming changes in composer.lock + git checkout --theirs composer.lock + + # run composer lock to update the lock file + composer lock || zz_log e 'Please resolve composer.lock conflicts manually.' + fi + fi + + # Check if there are any package-lock conflicts during the rebase + if [ -f package-lock.json ]; then + if grep -q "^<<<<<<< \|^======= \|^>>>>>>> " package-lock.json; then + # accept incoming changes in package-lock.json + git checkout --theirs package-lock.json + + # run npm install to update the lock file + npm install || zz_log e 'Please resolve package-lock.json conflicts manually.' + fi + fi + + # Continue the rebase process + git rebase --continue +done + +# if rebase successful and push option is set, push force the changes +if [ "$?" -eq 0 -a -n "$push" ]; then + # Check if the branch is already pushed + if git rev-parse --verify --quiet origin/HEAD >/dev/null; then + zz_log i "Pushing to remote..." + git push --force-with-lease origin HEAD + else + zz_log i "Branch not pushed, skipping push..." + fi +fi diff --git a/git-fix-up/test.bats b/git-fix-up/test.bats new file mode 100644 index 0000000..dd4ff52 --- /dev/null +++ b/git-fix-up/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix-up is installed on PATH and syntactically valid" { + command -v git-fix-up + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-fix/README.md b/git-fix/README.md new file mode 100644 index 0000000..92393f8 --- /dev/null +++ b/git-fix/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-fix + +Dispatch to git-fix-<subcommand> utilities. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-fix` (invoke via `git fix`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-fix/package.json b/git-fix/package.json new file mode 100644 index 0000000..6b2c5c5 --- /dev/null +++ b/git-fix/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-fix", + "version": "1.0.0", + "description": "Dispatch to git-fix-<subcommand> utilities", + "private": true, + "bin": { + "git-fix": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-fix/run.sh b/git-fix/run.sh new file mode 100755 index 0000000..c875978 --- /dev/null +++ b/git-fix/run.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# This script is a utility to dispatch calls to various utilities with the same prefix. +zz_dispatch $0 "$@" diff --git a/git-fix/test.bats b/git-fix/test.bats new file mode 100644 index 0000000..119acfe --- /dev/null +++ b/git-fix/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-fix is installed on PATH and syntactically valid" { + command -v git-fix + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-forall/README.md b/git-forall/README.md new file mode 100644 index 0000000..cc236dd --- /dev/null +++ b/git-forall/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-forall + +Execute a command for all files in the repository. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-forall` (invoke via `git forall`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-forall/package.json b/git-forall/package.json new file mode 100644 index 0000000..71e1ef0 --- /dev/null +++ b/git-forall/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-forall", + "version": "1.0.0", + "description": "Execute a command for all files in the repository", + "private": true, + "bin": { + "git-forall": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-forall/run.sh b/git-forall/run.sh new file mode 100755 index 0000000..141b540 --- /dev/null +++ b/git-forall/run.sh @@ -0,0 +1,7 @@ +#!/bin/sh +( + git ls-files --exclude-standard + git ls-files --exclude-standard --others +) | while read f; do + $* $f +done diff --git a/git-forall/test.bats b/git-forall/test.bats new file mode 100644 index 0000000..ea02144 --- /dev/null +++ b/git-forall/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-forall is installed on PATH and syntactically valid" { + command -v git-forall + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-getcommit/README.md b/git-getcommit/README.md new file mode 100644 index 0000000..942bdd5 --- /dev/null +++ b/git-getcommit/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-getcommit + +List git history and ask for a commit to fix up. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-getcommit` (invoke via `git getcommit`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-getcommit/package.json b/git-getcommit/package.json new file mode 100644 index 0000000..fa767e9 --- /dev/null +++ b/git-getcommit/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-getcommit", + "version": "1.0.0", + "description": "List git history and ask for a commit to fix up", + "private": true, + "bin": { + "git-getcommit": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-getcommit/run.sh b/git-getcommit/run.sh new file mode 100755 index 0000000..69ac704 --- /dev/null +++ b/git-getcommit/run.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "List git history and asks for commit" $0 "$@" <<-help + f - force allow overwritting pushed history + p - previous show commit previous to the one specified + - sha sha sha commit to fix from after + help +) + +#### Go to repository root +cd "$(git rev-parse --show-toplevel)" + +# Fetch updates from the remote repository +git fetch --progress --prune --recurse-submodules=no origin >/dev/null + +if [ -z "$sha" ]; then + if [ -n "$force" ]; then + zz_log w "Force mode enabled, overwriting pushed history" + git forceable >&2 + else + git fixable >&2 + fi + sha=$(zz_prompt "Which commit?") +fi + + +#### A sha of '0' means the very first commit in the current history +if [ "$sha" = "0" ]; then + sha=$(git rev-list --max-parents=0 HEAD | tail -1) +fi + +#### Display commit to fixup, keep only the sha, remove new line +sha=$(git rev-parse --verify "$sha^{commit}" | cut -d' ' -f1 | tr -d '\n') + +if [ -n "$previous" ]; then + git log --pretty=%P -1 "$sha" +else + echo "$sha" +fi diff --git a/git-getcommit/test.bats b/git-getcommit/test.bats new file mode 100644 index 0000000..ede6bca --- /dev/null +++ b/git-getcommit/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-getcommit is installed on PATH and syntactically valid" { + command -v git-getcommit + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-integrate/README.md b/git-integrate/README.md new file mode 100644 index 0000000..2ed8efc --- /dev/null +++ b/git-integrate/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-integrate + +Integrate modifications from the remote repository. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-integrate` (invoke via `git integrate`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-integrate/package.json b/git-integrate/package.json new file mode 100644 index 0000000..6b818a1 --- /dev/null +++ b/git-integrate/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-integrate", + "version": "1.0.0", + "description": "Integrate modifications from the remote repository", + "private": true, + "bin": { + "git-integrate": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-integrate/run.sh b/git-integrate/run.sh new file mode 100755 index 0000000..fd1af15 --- /dev/null +++ b/git-integrate/run.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +#### GOTO DIRECTORY +cd "$(git rev-parse --show-toplevel)" + +pwd + +#### CONFIGURE REPO +git config core.autocrlf false + +#### INTEGRATE MODIFICATIONS +echo 'Integrate modifications...' +git status --porcelain | sed 's/\(.*\)/-\1/g' | while read status; do + index=$(echo $status | cut -c2 | sed -e 's/^ *//' -e 's/ *$//') + local=$(echo $status | cut -c3 | sed -e 's/^ *//' -e 's/ *$//') + file=$(echo $status | cut -c4- | sed -e 's/^ *//' -e 's/ *$//') + + if [ "$local" = 'M' -o "$local" = '?' ]; then + if git diff -a -b -w --quiet HEAD -- "$file" 2>/dev/null; then + if git checkout --quiet -- "$file" 2>/dev/null; then + echo -n "." + else + echo "Add new file: $file" + git add -- "$file" + fi + else + echo + echo "File changed: $file" + git add -- "$file" + fi + fi +done + +#### BACK +cd - >/dev/null diff --git a/git-integrate/test.bats b/git-integrate/test.bats new file mode 100644 index 0000000..639879b --- /dev/null +++ b/git-integrate/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-integrate is installed on PATH and syntactically valid" { + command -v git-integrate + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-pick/README.md b/git-pick/README.md new file mode 100644 index 0000000..41ad33f --- /dev/null +++ b/git-pick/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-pick + +Pick files from a specific commit. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-pick` (invoke via `git pick`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-pick/package.json b/git-pick/package.json new file mode 100644 index 0000000..f513f46 --- /dev/null +++ b/git-pick/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-pick", + "version": "1.0.0", + "description": "Pick files from a specific commit", + "private": true, + "bin": { + "git-pick": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-pick/run.sh b/git-pick/run.sh new file mode 100755 index 0000000..9b344e8 --- /dev/null +++ b/git-pick/run.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "Pick files from a specific commit" $0 "$@" <<-help + c commit commit commit sha to pick from (if not provided, will prompt) + - path path path to restore (default: current directory) + help +) + +#### Go to repository root +cd "$(git rev-parse --show-toplevel)" + +# If no commit is provided, use git getcommit to select one +if [ -z "$commit" ]; then + commit=$(git getcommit) + if [ $? -ne 0 ] || [ -z "$commit" ]; then + zz_log e "No commit selected or invalid commit" + exit 1 + fi +fi + +# If no path is provided, use current prefix (relative to repo root) +if [ -z "$path" ]; then + path=$(git rev-parse --show-prefix) + # If path is empty (we're at repo root), use current directory + if [ -z "$path" ]; then + path="." + fi +fi + +zz_log i "Picking files from commit: $commit" +zz_log i "Target path: $path" + +# Execute the git restore command +git restore --source="$commit" --staged --worktree "$path" + +if [ $? -eq 0 ]; then + zz_log s "Successfully picked files from commit $commit to $path" +else + zz_log e "Failed to pick files from commit $commit" + exit 1 +fi diff --git a/git-pick/test.bats b/git-pick/test.bats new file mode 100644 index 0000000..b259d70 --- /dev/null +++ b/git-pick/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-pick is installed on PATH and syntactically valid" { + command -v git-pick + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-release-alpha/README.md b/git-release-alpha/README.md new file mode 100644 index 0000000..c996473 --- /dev/null +++ b/git-release-alpha/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-release-alpha + +Squash-merge the current feature branch into develop. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-release-alpha` (invoke via `git release-alpha`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-release-alpha/package.json b/git-release-alpha/package.json new file mode 100644 index 0000000..2926268 --- /dev/null +++ b/git-release-alpha/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-release-alpha", + "version": "1.0.0", + "description": "Squash-merge the current feature branch into develop", + "private": true, + "bin": { + "git-release-alpha": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-release-alpha/run.sh b/git-release-alpha/run.sh new file mode 100755 index 0000000..57a00f3 --- /dev/null +++ b/git-release-alpha/run.sh @@ -0,0 +1,117 @@ +#!/bin/sh + +# This script performs a squash merge of the current feature branch into the develop branch. +# It enforces conventional commit message standards, handles breaking changes, and optionally pushes to remote. + +# Parse arguments and print help if needed +eval $( + zz_args "Release squashed current branch to develop branch" $0 "$@" <<-help + m msg msg Message to use for the squash merge commit + o - occ Override the commit message to use the most occurring commit type + p - push Push the changes to the remote repository after merging +help +) + +# Ensure commit message is provided +if [ -z "$msg" ]; then + zz_log e "You must provide a message for the squash merge commit using the -m option." + exit 1 +fi + +# Change to repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Get the current branch name +current_branch=$(git rev-parse --abbrev-ref HEAD) + +# Ensure the script is run from a feature branch (enforces workflow discipline) +if ! echo "$current_branch" | grep -qE "^feature/"; then + zz_log e "You must be on a feature/xxx branch to release unstable version" + zz_log i "Current branch: $current_branch" + exit 1 +fi + +# Extract feature name from branch +feature=$(echo "$current_branch" | sed 's/^feature\///') + +# Update the commit message to include the feature name as a scope (conventional commit) +msg=$(echo "$msg" | sed -E "s/^([a-z]+):/\1($feature):/") + +# Stash any uncommitted changes before proceeding +zz_log i "Stashing current changes..." +stash="Before finishing $current_branch branch" +git stash push --include-untracked -m "$stash" + +# Check for breaking changes in commit messages and update the commit message accordingly +if git log "$current_branch" --pretty=format:"%s" | grep -Eq "(\!:)" || git log --grep="BREAKING-CHANGE" --oneline "$current_branch" --grep="BREAKING CHANGE" | grep -q .; then + zz_log w "Breaking change found in $current_branch commit messages" + zz_log - "updating conventional commit message to reflect this..." + # Add '!' to the commit type if not already present + msg=$(echo $msg | sed -E 's/^([a-z]+(\([^)]+\))?):/\1!:/') +else + zz_log s "No breaking change found in commit messages" +fi + +all_types=$(git log "$current_branch" --pretty=format:"%s" | grep -oE "^([a-z]+)" | sort | uniq -c | sort -nr) + +# If all commit messages start with the same conventional commit type, start the commit message with that type. +# Else keep the most occurring first word. +# Else force "feat" if at least one commit message starts with "feat" +if echo "$all_types" | wc -l | grep -q '^1$'; then + first_word=$(echo "$all_types" | awk '{print $2}') + msg=$(echo "$msg" | sed -E "s/^( *[a-z]+)/$first_word/") + zz_log i "All commit messages start with '$first_word', updating commit message to start with it..." +elif [ -n "$occ" ]; then + first_word=$(echo "$all_types" | head -n 1 | awk '{print $2}') + msg=$(echo "$msg" | sed -E "s/^( *[a-z]+)/$first_word/") + zz_log i "Most occurring first word is '$first_word', updating commit message to start with it..." +elif echo "$all_types" | grep -q '^ *feat'; then + first_word=feat + msg=$(echo "$msg" | sed -E "s/^( *[a-z]+)/$first_word/") + zz_log i "At least one commit message starts with 'feat', updating commit message to start with it..." +else + zz_log i "No specific commit type found, keeping message as is..." +fi + +# Prevent git editor prompt during commit +export GIT_EDITOR=: + +# Perform the squash merge of the feature branch into develop + +zz_log i "Finishing $current_branch with squash merge to develop..." +git fetch origin || zz_log w "Failed to fetch origin" + +if ! git checkout develop; then + zz_log e "Failed to checkout develop branch" + git stash list | grep -q "$stash" && git stash pop + exit 1 +fi + +if ! git merge --squash "$current_branch"; then + zz_log e "Failed to squash merge $current_branch into develop" + git stash list | grep -q "$stash" && git stash pop + exit 1 +fi + +if ! git commit -m "$msg"; then + zz_log e "Failed to commit squash merge for $current_branch" + git stash list | grep -q "$stash" && git stash pop + exit 1 +fi + +# Restore stashed changes and log result +zz_log s "Feature $feature successfully finished and squashed to develop" + +# Restore stashed changes if any +git stash list | grep -q "$stash" && git stash pop + +# Push changes to remote if requested +if [ -n "$push" ]; then + zz_log i "Pushing changes to remote repository..." + git push origin develop && + zz_log s "Changes pushed to remote repository successfully" || + zz_log e "Failed to push changes to remote repository" +fi + +# Switch back to the original feature branch +git checkout "$current_branch" diff --git a/git-release-alpha/test.bats b/git-release-alpha/test.bats new file mode 100644 index 0000000..b0c2efa --- /dev/null +++ b/git-release-alpha/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-release-alpha is installed on PATH and syntactically valid" { + command -v git-release-alpha + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-release-beta/README.md b/git-release-beta/README.md new file mode 100644 index 0000000..ff187fd --- /dev/null +++ b/git-release-beta/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-release-beta + +Start a new release branch using Git Flow. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-release-beta` (invoke via `git release-beta`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-release-beta/package.json b/git-release-beta/package.json new file mode 100644 index 0000000..5c97e0f --- /dev/null +++ b/git-release-beta/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-release-beta", + "version": "1.0.0", + "description": "Start a new release branch using Git Flow", + "private": true, + "bin": { + "git-release-beta": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-release-beta/run.sh b/git-release-beta/run.sh new file mode 100755 index 0000000..19a303e --- /dev/null +++ b/git-release-beta/run.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +#### Goto repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +#### GET BUMP VERSION +GBV=$(gv -showvariable MajorMinorPatch) +if [ -z "$GBV" ]; then + zz_log e "Cannot compute release version" + exit 1 +fi + +#### EXIT IF A DIFFERENT RELEASE ALREADY EXISTS +# Compare against branches directly (not the .git/RELEASE file) so a stale +# file left over from a prior run can never block/mask the real state. +other=$(git branch --list 'release/*' | sed 's/^[* ]*release\///' | grep -vFx "$GBV") +if [ -n "$other" ]; then + zz_log e "Other release exists, cannot proceed: $(printf '%s' "$other" | tr '\n' ' ')" + exit 1 +fi + +#### PREVENT GIT EDITOR PROMPT +export GIT_EDITOR=: + +#### STEP: create the release branch (idempotent -- resume if already started) +if git show-ref --verify --quiet "refs/heads/release/$GBV"; then + zz_log i "Release branch release/$GBV already exists, resuming" + if [ "$(git branch --show-current)" != "release/$GBV" ] && ! git checkout "release/$GBV"; then + zz_log e "Cannot switch to release/$GBV" + exit 1 + fi +elif ! git flow release start "$GBV"; then + zz_log e "Failed to start release $GBV" + exit 1 +fi + +#### STEP: record release state + push (safe to repeat -- push is a no-op +#### once the remote already has the branch) +printf '%s\n' "$GBV" >.git/RELEASE +if ! git push origin "release/$GBV"; then + zz_log e "Cannot push release/$GBV, re-run this command to retry" + exit 1 +fi diff --git a/git-release-beta/test.bats b/git-release-beta/test.bats new file mode 100644 index 0000000..e41c90b --- /dev/null +++ b/git-release-beta/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-release-beta is installed on PATH and syntactically valid" { + command -v git-release-beta + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-release-hotfix/README.md b/git-release-hotfix/README.md new file mode 100644 index 0000000..86d0f10 --- /dev/null +++ b/git-release-hotfix/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-release-hotfix + +Create a hotfix branch using Git Flow. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-release-hotfix` (invoke via `git release-hotfix`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-release-hotfix/package.json b/git-release-hotfix/package.json new file mode 100644 index 0000000..0c412c4 --- /dev/null +++ b/git-release-hotfix/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-release-hotfix", + "version": "1.0.0", + "description": "Create a hotfix branch using Git Flow", + "private": true, + "bin": { + "git-release-hotfix": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-release-hotfix/run.sh b/git-release-hotfix/run.sh new file mode 100755 index 0000000..c5f773e --- /dev/null +++ b/git-release-hotfix/run.sh @@ -0,0 +1,115 @@ +#!/bin/sh + +#### Goto repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Parse arguments and print help if needed +eval $( + zz_args "Create HotFix branch" $0 "$@" <<-help + r - rebase force rebase of current commits onto hotfix branch + s - stash stash current staged changes before creating hotfix branch and reapply them after +help +) + +#### GET last vX.Y.Z tag on the main branch and replace the last number with 'X' (leading 'v' is kept) +main_tag=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" main) + +if [ -z "$main_tag" ]; then + zz_log e "No tag found on main branch" + exit 1 +else + zz_log i "Current version is $main_tag" +fi + +hotfix=$(echo "$main_tag" | sed -E 's/([0-9]+)\.([0-9]+)\.([0-9]+)/\1.\2.X/') + +# Capture develop's tip before we potentially checkout the hotfix branch +# below, so the rebase-safety check further down always evaluates develop +# itself rather than whatever branch happens to be checked out at the time. +develop_rev=$(git rev-parse develop) + +#### STEP: resolve the hotfix branch (idempotent -- reuse it if a previous +#### run already created it, instead of failing on `git flow hotfix start`) +resumed="" +if git show-ref --verify --quiet "refs/heads/hotfix/$hotfix"; then + resumed=true + zz_log i "Hotfix branch hotfix/$hotfix already exists, resuming" + if [ "$(git branch --show-current)" != "hotfix/$hotfix" ] && ! git checkout "hotfix/$hotfix"; then + zz_log e "Cannot switch to hotfix/$hotfix" + exit 1 + fi +fi + +# Check if all commits since the last tag are conventional commits of 'fix:' type +# or if rebase is forced via command line option +if [ -n "$rebase" ]; then + zz_log i "Rebase forced via command line option, will rebase commits onto hotfix branch" +elif git log --reverse --pretty=oneline --format=%B develop --not origin/develop --no-merges | grep -vE "^$|^fix(\(.+\))?:" >/dev/null; then + zz_log w "There are commits since $main_tag that are not of type 'fix:', creating hotfix branch only" + unset rebase +else + zz_log i "All commits since $main_tag are of type 'fix:', creating hotfix branch and rebasing current history + stash on top of it" + rebase=true +fi + +# If rebase needed, check that develop branch has not been pushed since last +# tag and that its tip isn't a merge commit -- either makes it unsafe to reset +# develop back to the main tag afterwards. Refresh the remote-tracking ref +# first so a stale local origin/develop can't mask the first case, and check +# develop's captured tip explicitly (not HEAD, which may now be the hotfix +# branch if this run resumed an already-created one). +if [ -n "$rebase" ]; then + if ! git fetch origin develop >/dev/null 2>&1; then + zz_log e "Cannot fetch develop branch from remote" + exit 1 + fi + if [ "$develop_rev" = "$(git rev-parse origin/develop)" ] || [ "$(git rev-list --parents -1 "$develop_rev" | wc -w)" -gt 2 ]; then + zz_log e "Develop branch has already been pushed or its tip is a merge commit, cannot rebase safely, aborting" + exit 1 + fi +fi + +#### STEP: stash local changes, if any, before creating the branch (idempotent +#### -- reclaim a stash left over from an interrupted previous run instead of +#### stashing again on top of it or leaving it stuck) +pending_stash=$(git stash list | grep -m1 "Hotfix stash:" | cut -d: -f1) + +if [ -z "$resumed" ]; then + if [ -n "$(git status --porcelain)" ]; then + zz_log w "Working directory is not clean. Stashing staged changes before creating hotfix branch..." + git stash save -k -m "Hotfix stash: $(date +%Y-%m-%d-%H-%M-%S)" + pending_stash=$(git stash list | grep -m1 "Hotfix stash:" | cut -d: -f1) + else + zz_log i "Working directory is clean, no need to stash changes" + fi +elif [ -n "$pending_stash" ]; then + zz_log i "Found stash left over from a previous run ($pending_stash), will reapply it" +fi + +# Set GIT_EDITOR to no-op to avoid opening editor during rebase or cherry-pick +export GIT_EDITOR=: + +#### STEP: create hotfix branch (bail out if it fails, so we don't pop the +#### stash or rebase onto the wrong branch) +if [ -z "$resumed" ] && ! git flow hotfix start "$hotfix"; then + zz_log e "Failed to start hotfix branch $hotfix" + exit 1 +fi + +#### STEP: reapply any pending stash (idempotent -- no-op when there is none) +if [ -n "$pending_stash" ]; then + zz_log i "Applying stashed changes ($pending_stash)..." + if ! git stash pop --index "$pending_stash"; then + zz_log e "Stash pop had conflicts. Resolve them, then re-run this command to continue." + exit 1 + fi +fi + +#### STEP: pick all "fix" commits from develop branch and rebase them onto +#### hotfix branch, then reset develop branch to the main tag. Naturally +#### idempotent: it only moves commits still ahead of the hotfix branch, so a +#### re-run after everything already moved is a no-op. +if [ -n "$rebase" ]; then + zz_log i "Rebasing develop commits onto hotfix branch..." + git fix base -p "hotfix/$hotfix" develop +fi diff --git a/git-release-hotfix/test.bats b/git-release-hotfix/test.bats new file mode 100644 index 0000000..5237a85 --- /dev/null +++ b/git-release-hotfix/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-release-hotfix is installed on PATH and syntactically valid" { + command -v git-release-hotfix + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-release-prod/README.md b/git-release-prod/README.md new file mode 100644 index 0000000..fc418ff --- /dev/null +++ b/git-release-prod/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-release-prod + +Finish a release or hotfix branch using Git Flow. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-release-prod` (invoke via `git release-prod`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-release-prod/package.json b/git-release-prod/package.json new file mode 100644 index 0000000..0f8c3f0 --- /dev/null +++ b/git-release-prod/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-release-prod", + "version": "1.0.0", + "description": "Finish a release or hotfix branch using Git Flow", + "private": true, + "bin": { + "git-release-prod": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-release-prod/run.sh b/git-release-prod/run.sh new file mode 100755 index 0000000..ea9c9a3 --- /dev/null +++ b/git-release-prod/run.sh @@ -0,0 +1,184 @@ +#!/bin/sh + +# Parse arguments and print help if needed +eval $( + zz_args "Release production branch" $0 "$@" <<-help +help +) + +# Change to repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +# Refresh tags up front so the "already finished" checks below (which rely on +# tag existence) see what's actually on the remote, not a stale local view. +git fetch origin --tags >/dev/null 2>&1 + +# git flow finish tags as <gitflow.prefix.versiontag><version> (default "v"). +versiontag_prefix=$(git config gitflow.prefix.versiontag 2>/dev/null) +versiontag_prefix="${versiontag_prefix:-v}" + +#### STEP: resolve which flow branch to finish ----------------------------- +# Prefer the branch we're already on, so a checked-out hotfix/release branch +# is never overridden by an unrelated one that also happens to exist locally. +current=$(git branch --show-current) +case "$current" in +hotfix/*) + flow=hotfix + name=${current#hotfix/} + zz_log i "On hotfix branch: {Yellow $name}" + ;; +release/*) + flow=release + name=${current#release/} + zz_log i "On release branch: {Blue $name}" + ;; +esac + +# Not currently on a flow branch: fall back to discovery, but refuse to guess +# when more than one candidate exists -- silently picking one risks finishing +# (merging + tagging + pushing) the wrong release/hotfix. +if [ -z "$flow" ]; then + hotfixes=$(git branch --list 'hotfix/*' | sed 's/^[* ]*hotfix\///') + hotfix_count=$(printf '%s\n' "$hotfixes" | grep -c .) + + if [ "$hotfix_count" -gt 1 ]; then + zz_log e "Multiple hotfix branches found, checkout the one to finish first: $(printf '%s' "$hotfixes" | tr '\n' ' ')" + exit 1 + elif [ "$hotfix_count" -eq 1 ]; then + flow=hotfix + name=$hotfixes + zz_log i "Hotfix branch found: {Yellow $name}" + elif [ -f .git/RELEASE ]; then + name=$(cat .git/RELEASE) + # Cleanup-only case: `git flow finish` already ran to completion on a + # prior run (it deletes the release branch on success), but the + # trailing bump-tag/cleanup step never happened. There's no branch + # left to check out, so finish the leftover cleanup here and stop. + if ! git show-ref --verify --quiet "refs/heads/release/$name" \ + && git rev-parse -q --verify "refs/tags/${versiontag_prefix}${name}" >/dev/null; then + zz_log i "Release $name already finished (branch gone, tag exists) -- cleaning up only" + bump-tag "$name" + rm -f .git/RELEASE + exit 0 + fi + flow=release + zz_log i "Release branch found: {Blue $name}" + else + releases=$(git branch --list 'release/*' | sed 's/^[* ]*release\///') + release_count=$(printf '%s\n' "$releases" | grep -c .) + + if [ "$release_count" -gt 1 ]; then + zz_log e "Multiple release branches found, checkout the one to finish first: $(printf '%s' "$releases" | tr '\n' ' ')" + exit 1 + elif [ "$release_count" -eq 1 ]; then + flow=release + name=$releases + zz_log i "Release branch found: {Blue $name}" + fi + fi +fi + +# Exit if no flow branch is found +if [ -z "$flow" ] || [ -z "$name" ]; then + zz_log e "No flow branch found" + exit 1 +fi + +# Switch to the resolved branch +if ! git checkout "$flow/$name" >/dev/null 2>&1; then + zz_log e "Cannot switch to $flow/$name branch" + exit 1 +fi +zz_log s "On branch: {Blue $flow/$name}" + +#### STEP: determine target version + whether finish already happened ------ +GBV=$(gv -showvariable MajorMinorPatch) +if [ -z "$GBV" ]; then + zz_log e "Cannot get version from .gitversion" + exit 1 +fi +zz_log i "Bump version: {Blue $GBV}" + +# If the finish tag already exists, finish already ran to completion on a +# prior run -- resume at cleanup only instead of redoing the merge/tag/push. +finished="" +if git rev-parse -q --verify "refs/tags/${versiontag_prefix}${GBV}" >/dev/null; then + zz_log i "Tag ${versiontag_prefix}${GBV} already exists, release already finished -- resuming cleanup only" + finished=true +fi + +# Prevent git editor prompt during finish +export GIT_EDITOR=: + +if [ -z "$finished" ]; then + + # Ensure working directory is clean + if [ -n "$(git status --porcelain)" ]; then + zz_log e "Working directory is not clean. Please commit or stash changes." + exit 1 + fi + + # Ensure the flow branch has an up-to-date remote + if ! git fetch origin >/dev/null 2>&1; then + zz_log e "Cannot fetch from remote" + exit 1 + fi + + # is-ancestor(remote, local) succeeds only when the remote tip is already + # contained in local -- i.e. local is not behind. Passed the other way + # round it would succeed while local is behind (needs a pull). + if ! git merge-base --is-ancestor "$(git rev-parse "refs/remotes/origin/$flow/$name")" "$(git rev-parse "$flow/$name")" ; then + zz_log e "$flow/$name branch is not up-to-date with remote. Please pull the latest changes." + exit 1 + fi + + #### STEP: bump version/changelog + commit (idempotent -- skip if a + #### previous run already made this exact commit) + if [ "$(git log -1 --pretty=%s)" = "chore(release): $GBV" ]; then + zz_log i "Version & CHANGELOG already committed for $GBV, skipping bump" + else + if ! bump-changelog -f "$GBV" -b -m; then + zz_log e "Cannot update version & CHANGELOG" + exit 1 + fi + zz_log s "Version & CHANGELOG updated to: {B $GBV}" + if ! git commit -am "chore(release): $GBV"; then + zz_log e "Cannot commit version & CHANGELOG" + exit 1 + fi + fi + + #### STEP: push (safe to repeat -- no-op once the remote already has it) + if ! git push --set-upstream origin "$flow/$name"; then + zz_log e "Cannot push $flow/$name, re-run this command to retry" + exit 1 + fi + zz_log s "Version & CHANGELOG committed and pushed" + + # Ensure develop branch is up-to-date before finishing release + if ! git fetch origin develop:develop; then + zz_log e "Cannot fetch develop branch from remote" + exit 1 + fi + + if ! git merge-base --is-ancestor "$(git rev-parse origin/develop)" "$(git rev-parse develop)" ; then + zz_log e "Develop branch is not up-to-date with remote. Please pull the latest changes." + exit 1 + fi + + #### STEP: finish (merge to main/develop + tag + push) + # git flow finish prepends gitflow.prefix.versiontag to --tagname itself, + # so pass the bare version here -- prefixing it ourselves would tag "vv$GBV". + if git flow "$flow" finish "$name" --push --tagname "$GBV" --message "$GBV" ; then + zz_log s "Release finished: {B $GBV}" + else + zz_log e "Cannot finish release. Please fix the issues, commit any pending changes, then re-run this command to retry -- or finish manually with:" + zz_log - " git flow $flow finish $name --push --tagname $GBV --message $GBV" + exit 1 + fi +fi + +#### STEP: tag follow-up + cleanup (both idempotent, safe to repeat) ------- +bump-tag "$GBV" +# Clear release state only once the release has actually finished. +rm -f .git/RELEASE diff --git a/git-release-prod/test.bats b/git-release-prod/test.bats new file mode 100644 index 0000000..92809b8 --- /dev/null +++ b/git-release-prod/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-release-prod is installed on PATH and syntactically valid" { + command -v git-release-prod + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-release/README.md b/git-release/README.md new file mode 100644 index 0000000..be818af --- /dev/null +++ b/git-release/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-release + +Dispatch to git-release-<subcommand> utilities. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-release` (invoke via `git release`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-release/package.json b/git-release/package.json new file mode 100644 index 0000000..52e67bf --- /dev/null +++ b/git-release/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-release", + "version": "1.0.0", + "description": "Dispatch to git-release-<subcommand> utilities", + "private": true, + "bin": { + "git-release": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-release/run.sh b/git-release/run.sh new file mode 100755 index 0000000..c875978 --- /dev/null +++ b/git-release/run.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# This script is a utility to dispatch calls to various utilities with the same prefix. +zz_dispatch $0 "$@" diff --git a/git-release/test.bats b/git-release/test.bats new file mode 100644 index 0000000..511ee44 --- /dev/null +++ b/git-release/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-release is installed on PATH and syntactically valid" { + command -v git-release + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-unset/README.md b/git-unset/README.md new file mode 100644 index 0000000..ec0c1f6 --- /dev/null +++ b/git-unset/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-unset + +Unset all Git config keys starting with the given prefix. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-unset` (invoke via `git unset`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-unset/package.json b/git-unset/package.json new file mode 100644 index 0000000..26ffc98 --- /dev/null +++ b/git-unset/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-unset", + "version": "1.0.0", + "description": "Unset all Git config keys starting with the given prefix", + "private": true, + "bin": { + "git-unset": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-unset/run.sh b/git-unset/run.sh new file mode 100755 index 0000000..5391178 --- /dev/null +++ b/git-unset/run.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +#### Goto repository root +cd "$(git rev-parse --show-toplevel)" >/dev/null + +#### Unset all git config keys starting with the given prefix +git config ${2:---local} --get-regexp "^${1:-[a-z]+}\\." | cut -d ' ' -f 1 | while read alias_key; do + git config ${2:---local} --unset-all "$alias_key" +done diff --git a/git-unset/test.bats b/git-unset/test.bats new file mode 100644 index 0000000..ee9a3f2 --- /dev/null +++ b/git-unset/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-unset is installed on PATH and syntactically valid" { + command -v git-unset + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/git-workspaces/README.md b/git-workspaces/README.md new file mode 100644 index 0000000..cf8466f --- /dev/null +++ b/git-workspaces/README.md @@ -0,0 +1,20 @@ +<!-- @format --> + +# git-workspaces + +List workspace directories and affected workspaces. + +Part of [`tomgrv/scripts`](https://github.com/tomgrv/scripts) — installed +and linked onto `PATH` as `git-workspaces` (invoke via `git workspaces`, since +git resolves any `git-*` executable on `PATH` as a subcommand). + +## Dependencies + +Declared via `zz_use` at the top of `run.sh` and resolved on demand +(installed if and only if missing) — see `run.sh` for the exact list. + +## Tests + +```sh +bats test.bats +``` diff --git a/git-workspaces/package.json b/git-workspaces/package.json new file mode 100644 index 0000000..72c6fa0 --- /dev/null +++ b/git-workspaces/package.json @@ -0,0 +1,12 @@ +{ + "name": "git-workspaces", + "version": "1.0.0", + "description": "List workspace directories and affected workspaces", + "private": true, + "bin": { + "git-workspaces": "run.sh" + }, + "scripts": { + "test": "bats test.bats" + } +} diff --git a/git-workspaces/run.sh b/git-workspaces/run.sh new file mode 100755 index 0000000..1e22918 --- /dev/null +++ b/git-workspaces/run.sh @@ -0,0 +1,69 @@ +#!/bin/sh + +# Function to print help and manage arguments +eval $( + zz_args "List workspace directories and affected workspaces" $0 "$@" <<-help + r range range Git range to check for affected workspaces (prints only affected workspaces) +help +) + +#### Go to repository root +cd "$(git rev-parse --show-toplevel)" + +list_workspace_dirs() { + if [ -f "package.json" ] && command -v jq >/dev/null 2>&1; then + local workspaces + workspaces=$(jq -r '.workspaces[]? // empty' package.json 2>/dev/null || true) + + if [ -n "$workspaces" ]; then + echo "$workspaces" | while read -r workspace_pattern; do + if echo "$workspace_pattern" | grep -q '\*'; then + # Expand simple glob patterns like "packages/*" or "src/*" + find . -path "./$workspace_pattern" -type d -mindepth 1 -maxdepth 2 2>/dev/null || true + else + if [ -d "$workspace_pattern" ]; then + echo "./$workspace_pattern" + fi + fi + done + return 0 + fi + fi + + # Fallback: list all immediate subdirectories + find . -mindepth 1 -maxdepth 1 -type d +} + +get_affected_workspaces() { + local range="$1" + + # If no range provided, nothing to do + if [ -z "$range" ]; then + return 0 + fi + + # Get all changed files in the range and determine which workspaces they belong to + git log --name-only --pretty=format: "$range" 2>/dev/null \ + | while read -r line; do + if [ -n "$line" ]; then + # This is a file path, check which workspace it belongs to + list_workspace_dirs | while read -r workspace_dir; do + workspace_dir_clean=$(echo "$workspace_dir" | sed 's|^./||') + if echo "$line" | grep -q "^$workspace_dir_clean/"; then + echo "$workspace_dir" + fi + done + # Check for root-level files (not in any workspace subdirectory) + if ! echo "$line" | grep -q "/"; then + echo "." + fi + fi + done +} + +# Main execution logic +if [ -n "$range" ]; then + get_affected_workspaces "$range" | sort -u +else + list_workspace_dirs +fi diff --git a/git-workspaces/test.bats b/git-workspaces/test.bats new file mode 100644 index 0000000..c5ae2f5 --- /dev/null +++ b/git-workspaces/test.bats @@ -0,0 +1,17 @@ +#!/usr/bin/env bats + +load ../tests/helpers.bash + +setup() { + setup_scripts_path +} + +teardown() { + teardown_scripts_path +} + +@test "git-workspaces is installed on PATH and syntactically valid" { + command -v git-workspaces + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} diff --git a/package.json b/package.json index 6fc0172..56874d4 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,39 @@ "edit-script", "distribute-utils", "install-feature", - "configure-feature" + "configure-feature", + "git-align", + "git-autorebase", + "git-co", + "git-degit", + "git-fix", + "git-fix-author", + "git-fix-base", + "git-fix-blanks", + "git-fix-children", + "git-fix-date", + "git-fix-del", + "git-fix-emoji", + "git-fix-last", + "git-fix-lock", + "git-fix-message", + "git-fix-mode", + "git-fix-privacy", + "git-fix-prune", + "git-fix-rights", + "git-fix-secrets", + "git-fix-up", + "git-forall", + "git-getcommit", + "git-integrate", + "git-pick", + "git-release", + "git-release-alpha", + "git-release-beta", + "git-release-hotfix", + "git-release-prod", + "git-unset", + "git-workspaces" ], "prettier": { "insertPragma": true,