├── .pre-commit-config.yaml ├── bin ├── git-pileworktreepath ├── git-pilereset ├── git-pilebranchname ├── git-headpr ├── git-pilecleanupremotebranch ├── git-rebasepr ├── git-replacepr ├── git-updatepr ├── git-absorb └── git-submitpr ├── LICENSE └── README.md /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/shellcheck-py/shellcheck-py 3 | rev: v0.11.0.1 4 | hooks: 5 | - id: shellcheck 6 | -------------------------------------------------------------------------------- /bin/git-pileworktreepath: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Get the primary clone's path in the case of using worktrees 6 | root=$(git worktree list --porcelain | head -1 | cut -d" " -f2) 7 | if [[ $OSTYPE == darwin* ]]; then 8 | worktree_name=$(echo "$root" | md5) 9 | else 10 | worktree_name=$(echo "$root" | md5sum | cut -d" " -f1) 11 | fi 12 | 13 | echo "$HOME/.cache/git-pile/$worktree_name" 14 | -------------------------------------------------------------------------------- /bin/git-pilereset: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 6 | set -x 7 | fi 8 | 9 | worktree_dir="$(git pileworktreepath)" 10 | git worktree remove --force "$worktree_dir" &> /dev/null || true 11 | /usr/bin/env rm -rf "$worktree_dir" &> /dev/null || true 12 | 13 | # TODO: Should we optionally delete some branch? 14 | # TODO: Otherwise should we call it pileresetworktree? 15 | -------------------------------------------------------------------------------- /bin/git-pilebranchname: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly ref=$1 6 | if [[ "$ref" == -* ]]; then 7 | echo "error: invalid ref starts with dash: $ref" >&2 8 | exit 1 9 | fi 10 | 11 | branch=$(git show --no-patch --no-show-signature --format=%f "$ref") 12 | if [[ -z "$branch" ]]; then 13 | echo "error: no branch found for ref: $ref" >&2 14 | exit 1 15 | fi 16 | 17 | branch_name="${GIT_PILE_PREFIX:-}$(echo "$branch" | tr '[:upper:]' '[:lower:]' | sed -e 's/^\.*//' -e 's/\.lock$/-lock/')" 18 | echo "$branch_name" 19 | -------------------------------------------------------------------------------- /bin/git-headpr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 6 | set -x 7 | fi 8 | 9 | updatepr_args=() 10 | commit_args=() 11 | for arg in "$@" 12 | do 13 | case "$arg" in 14 | --squash) 15 | # always signoff to avoid pre-commit hook failures, this is ignored anyways 16 | commit_args+=(-m ignore -s) 17 | updatepr_args+=(--squash) 18 | ;; 19 | *) 20 | commit_args+=("$arg") 21 | ;; 22 | esac 23 | done 24 | 25 | head_sha=$(git rev-parse HEAD) 26 | git commit "${commit_args[@]:---}" 27 | git updatepr "$head_sha" "${updatepr_args[@]:-}" 28 | -------------------------------------------------------------------------------- /bin/git-pilecleanupremotebranch: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | quiet_arg="--quiet" 6 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 7 | set -x 8 | quiet_arg="" 9 | fi 10 | 11 | if [[ $# -ne 1 ]]; then 12 | echo "usage: git pilecleanupremotebranch " 13 | exit 1 14 | fi 15 | 16 | branch_name="$1" 17 | if ! git show-ref $quiet_arg refs/heads/"$branch_name"; then 18 | branch_name="$(git pilebranchname "$branch_name")" 19 | fi 20 | 21 | if ! git show-ref $quiet_arg refs/heads/"$branch_name"; then 22 | echo "error: branch named '$branch_name' does not exist" 23 | exit 1 24 | fi 25 | 26 | worktree_dir="$(git pileworktreepath)" 27 | git -C "$worktree_dir" push $quiet_arg --delete origin "$branch_name" || true 28 | git -C "$worktree_dir" switch --detach $quiet_arg 29 | git branch -D "$branch_name" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Keith Smiley (http://keith.so) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the 'Software'), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /bin/git-rebasepr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 6 | set -x 7 | fi 8 | 9 | rebase_args=() 10 | commit="" 11 | for arg in "$@" 12 | do 13 | case "$arg" in 14 | -i) 15 | rebase_args+=("-i") 16 | shift 17 | ;; 18 | *) 19 | if [[ -n "$commit" ]]; then 20 | echo "error: multiple commit args passed, '$commit' and '$arg'" >&2 21 | exit 1 22 | fi 23 | commit="$arg" 24 | ;; 25 | esac 26 | done 27 | 28 | readonly commit_to_rebase=${commit:-HEAD} 29 | branch_name="$(git pilebranchname "$commit_to_rebase")" 30 | if ! git show-ref --quiet "$branch_name"; then 31 | echo "error: branch '$branch_name' doesn't exist" >&2 32 | exit 1 33 | fi 34 | 35 | worktree_dir="$(git pileworktreepath)" 36 | if [[ ! -d "$worktree_dir" ]]; then 37 | git worktree add --quiet --force "$worktree_dir" "$branch_name" 38 | else 39 | git -C "$worktree_dir" switch --quiet "$branch_name" 40 | fi 41 | 42 | _detach_branch() { 43 | git -C "$worktree_dir" switch --detach --quiet 44 | } 45 | 46 | trap _detach_branch EXIT 47 | 48 | branch_with_remote=$(git rev-parse --abbrev-ref --symbolic-full-name "@{upstream}") 49 | if ! git -C "$worktree_dir" rebase "$branch_with_remote" "${rebase_args[@]:---}"; then 50 | # TODO: if multiple commits conflict this only handles the first one, after the continue it fails again, we need another mergetool call, in a loop 51 | if git -C "$worktree_dir" mergetool; then 52 | if ! GIT_EDITOR=true git -C "$worktree_dir" rebase --continue; then 53 | git -C "$worktree_dir" rebase --abort 54 | exit 1 55 | fi 56 | else 57 | git -C "$worktree_dir" rebase --abort 58 | exit 1 59 | fi 60 | fi 61 | 62 | # TODO: this skips push hooks, not sure if good? maybe pushing in the main repo would be better 63 | git -C "$worktree_dir" push --force-with-lease --quiet --no-verify 64 | -------------------------------------------------------------------------------- /bin/git-replacepr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 6 | set -x 7 | fi 8 | 9 | export GIT_SEQUENCE_EDITOR=true 10 | 11 | readonly commit_arg=${1:-HEAD} 12 | commit="$(git rev-parse "$commit_arg")" 13 | 14 | branch_name="$(git pilebranchname "$commit_arg")" 15 | if ! git show-ref --quiet "$branch_name"; then 16 | echo "error: branch '$branch_name' doesn't exist" >&2 17 | exit 1 18 | fi 19 | 20 | worktree_dir="$(git pileworktreepath)" 21 | if [[ ! -d "$worktree_dir" ]]; then 22 | git worktree add --quiet --force "$worktree_dir" "$branch_name" 23 | else 24 | git -C "$worktree_dir" switch --quiet "$branch_name" 25 | fi 26 | 27 | _detach_branch() { 28 | git -C "$worktree_dir" switch --detach --quiet 29 | } 30 | 31 | trap _detach_branch EXIT 32 | 33 | _ask() { 34 | read -p "$1" -n 1 -r 35 | if [[ "$REPLY" =~ ^[Yy]$ ]]; then 36 | echo "y" 37 | elif [[ "$REPLY" =~ ^[Nn]$ ]]; then 38 | echo "n" 39 | else 40 | echo >&2 41 | _ask "$1" 42 | fi 43 | } 44 | 45 | branch_with_remote=$(git -C "$worktree_dir" rev-parse --abbrev-ref --symbolic-full-name "@{upstream}") 46 | remote_name="${branch_with_remote%%/*}" 47 | remote_branch_name="${branch_with_remote#*/}" 48 | 49 | git -C "$worktree_dir" fetch --quiet "$remote_name" "$remote_branch_name" 50 | if ! git -C "$worktree_dir" diff --quiet "HEAD...@{upstream}"; then 51 | git -C "$worktree_dir" diff "HEAD...@{upstream}" 52 | answer=$(_ask "warning: upstream has new commits, would you like to pull those (or abort)? (y/n) ") 53 | if [[ "$answer" == y ]]; then 54 | git -C "$worktree_dir" pull 55 | else 56 | echo "warning: not updating PR, checkout '$branch_name', pull and try again" >&2 57 | exit 1 58 | fi 59 | fi 60 | 61 | upstream_branch=$(git rev-parse --abbrev-ref --symbolic-full-name "@{upstream}") 62 | merge_base=$(git -C "$worktree_dir" merge-base "$upstream_branch" HEAD) 63 | git -C "$worktree_dir" reset --hard "$merge_base" 64 | 65 | if ! git -C "$worktree_dir" cherry-pick "$commit" >/dev/null; then 66 | # TODO: if you ctrl-c out of the merge tool, it doesn't return false it exists the script only hitting the trap, so the cherry pick is not aborted 67 | if git -C "$worktree_dir" mergetool; then 68 | if ! git -C "$worktree_dir" -c core.editor=true cherry-pick --continue; then 69 | git -C "$worktree_dir" cherry-pick --abort || true 70 | _detach_branch 71 | echo "error: cherry picking failed, maybe there wasn't a commit to cherry pick?" >&2 72 | exit 1 73 | fi 74 | else 75 | git -C "$worktree_dir" cherry-pick --abort 76 | _detach_branch 77 | exit 1 78 | fi 79 | fi 80 | 81 | git -C "$worktree_dir" push --force-with-lease --quiet || echo "warning: failed to force push '$branch_name'" >&2 82 | -------------------------------------------------------------------------------- /bin/git-updatepr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 6 | set -x 7 | fi 8 | 9 | if [[ $# -lt 1 ]]; then 10 | echo "usage: $0 [--squash]" >&2 11 | exit 1 12 | fi 13 | 14 | export GIT_SEQUENCE_EDITOR=true 15 | 16 | readonly sha_or_branch_to_update=$1 17 | shift 18 | 19 | squash=false 20 | for arg in "$@" 21 | do 22 | case "$arg" in 23 | --squash) 24 | squash=true 25 | shift 26 | ;; 27 | *) 28 | ;; 29 | esac 30 | done 31 | 32 | # TODO: I think this is broken-ish as it includes all commits in between the PR sha and the one you're passing to update, which might be unexpected 33 | new_refspec=$(git rev-parse "${1:-HEAD}") 34 | base_refspec=$(git rev-parse HEAD) 35 | 36 | if [[ "$new_refspec" != "$base_refspec" ]]; then 37 | echo "error: need to fix before allowing this" >&2 38 | exit 1 39 | fi 40 | 41 | # Get the commit to update and the branch name 42 | if git show-ref --quiet --verify "refs/heads/$sha_or_branch_to_update"; then 43 | branch_name="$sha_or_branch_to_update" 44 | commit_to_update=$(git rev-parse --verify "$branch_name" 2>/dev/null) 45 | elif git cat-file -e "$sha_or_branch_to_update" 2>/dev/null; then 46 | commit_to_update="$sha_or_branch_to_update" 47 | branch_name="$(git pilebranchname "$commit_to_update")" 48 | 49 | # Check if we got a valid branch 50 | if ! git show-ref --quiet "$branch_name"; then 51 | echo "error: branch '$branch_name' doesn't exist" >&2 52 | exit 1 53 | fi 54 | else 55 | echo "error: invalid commit sha or branch name: '$sha_or_branch_to_update'" >&2 56 | exit 1 57 | fi 58 | 59 | worktree_dir="$(git pileworktreepath)" 60 | if [[ ! -d "$worktree_dir" ]]; then 61 | git worktree add --quiet --force "$worktree_dir" "$branch_name" 62 | else 63 | git -C "$worktree_dir" switch --quiet "$branch_name" 64 | fi 65 | 66 | _detach_branch() { 67 | git -C "$worktree_dir" switch --detach --quiet 68 | } 69 | 70 | trap _detach_branch EXIT 71 | 72 | _ask() { 73 | read -p "$1" -n 1 -r 74 | if [[ "$REPLY" =~ ^[Yy]$ ]]; then 75 | echo "y" 76 | elif [[ "$REPLY" =~ ^[Nn]$ ]]; then 77 | echo "n" 78 | else 79 | echo >&2 80 | _ask "$1" 81 | fi 82 | } 83 | 84 | branch_with_remote=$(git -C "$worktree_dir" rev-parse --abbrev-ref --symbolic-full-name "@{upstream}") 85 | remote_name="${branch_with_remote%%/*}" 86 | remote_branch_name="${branch_with_remote#*/}" 87 | 88 | git -C "$worktree_dir" fetch --quiet "$remote_name" "$remote_branch_name" 89 | if ! git -C "$worktree_dir" diff --quiet "HEAD...@{upstream}"; then 90 | git -C "$worktree_dir" diff "HEAD...@{upstream}" 91 | answer=$(_ask "warning: upstream has new commits, would you like to pull those (or abort)? (y/n) ") 92 | if [[ "$answer" == y ]]; then 93 | git -C "$worktree_dir" pull 94 | else 95 | echo "warning: not updating PR, checkout '$branch_name', pull and try again" >&2 96 | exit 1 97 | fi 98 | fi 99 | 100 | output_file=$(mktemp) 101 | if ! git -C "$worktree_dir" cherry-pick "$new_refspec^..$base_refspec" >"$output_file" 2>&1; then 102 | cat "$output_file" >&2 103 | # TODO if you exit in vim with :cq, it asks you if it was successful, i think if you hit yes it will continue even if it's not? 104 | if git -C "$worktree_dir" mergetool; then 105 | if ! git -C "$worktree_dir" -c core.editor=true cherry-pick --continue; then 106 | git -C "$worktree_dir" cherry-pick --abort 107 | echo "error: failed to cherry pick anything, was the commit you're adding empty on this branch?" >&2 108 | exit 1 109 | fi 110 | else 111 | git -C "$worktree_dir" cherry-pick --abort 112 | exit 1 113 | fi 114 | fi 115 | 116 | if [[ "$squash" == true ]]; then 117 | git -C "$worktree_dir" commit --quiet --signoff --no-verify --amend --fixup=HEAD~ 118 | git -C "$worktree_dir" rebase --quiet --interactive --autosquash HEAD~2 119 | git -C "$worktree_dir" push --force-with-lease --quiet || echo "warning: failed to force push '$branch_name'" >&2 120 | else 121 | git -C "$worktree_dir" push --quiet || echo "warning: failed to push '$branch_name'" >&2 122 | fi 123 | 124 | git rebase --quiet --interactive --autostash --exec "git commit --signoff --no-verify --amend --fixup '$commit_to_update'" "$new_refspec"^ 125 | git rebase --quiet --interactive --autostash --autosquash "$commit_to_update"^ 126 | -------------------------------------------------------------------------------- /bin/git-absorb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from typing import List, Dict, Tuple 4 | import argparse 5 | import collections 6 | import os 7 | import subprocess 8 | import sys 9 | 10 | RED = "\033[91m" 11 | RESET = "\033[0m" 12 | 13 | class _GitError(Exception): 14 | pass 15 | 16 | 17 | def _build_parser() -> argparse.ArgumentParser: 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument( 20 | "--squash", 21 | action="store_true", 22 | help="Squash the commit instead of adding a new commit to the branch", 23 | ) 24 | return parser 25 | 26 | 27 | def _run_git_command(args: List[str]) -> str: 28 | try: 29 | output: bytes = subprocess.check_output(["git"] + args) 30 | # TODO: got an exception here at some point 31 | return output.strip().decode("utf-8") 32 | except subprocess.CalledProcessError: 33 | raise _GitError() 34 | except UnicodeDecodeError: 35 | print(f"error: output was {output}") 36 | raise 37 | 38 | 39 | def _is_in_repo() -> bool: 40 | try: 41 | _run_git_command(["rev-parse", "--show-toplevel"]) 42 | return True 43 | except _GitError: 44 | return False 45 | 46 | 47 | def _get_staged_files() -> List[str]: 48 | statuses = _run_git_command( 49 | ["diff", "--cached", "--name-status"] 50 | ).splitlines() 51 | files = [] 52 | for status in statuses: 53 | # Modified files are formatted as Mfilepath 54 | # Renamed files are formatted as R%old pathnew path 55 | files.extend(status.split("\t")[1:]) 56 | 57 | return files 58 | 59 | 60 | def _get_modified_files() -> List[str]: 61 | return _run_git_command(["diff", "--name-only"]).splitlines() 62 | 63 | 64 | def _get_possible_shas() -> List[str]: 65 | return _run_git_command( 66 | ["log", "--no-show-signature", "--pretty=%H", "@{upstream}..HEAD"] 67 | ).splitlines() 68 | 69 | 70 | def _get_changed_files() -> Tuple[List[str], bool]: 71 | staged_files = _get_staged_files() 72 | if staged_files: 73 | return (staged_files, False) 74 | 75 | modified_files = _get_modified_files() 76 | if modified_files: 77 | return (modified_files, True) 78 | 79 | return ([], False) 80 | 81 | 82 | def _files_for_sha(sha: str) -> List[str]: 83 | return _run_git_command( 84 | ["show", "--no-show-signature", "--name-only", "--pretty=", sha] 85 | ).splitlines() 86 | 87 | 88 | def _shas_by_file( 89 | shas: List[str], changed_files: List[str] 90 | ) -> Dict[str, List[str]]: 91 | shas_by_file: Dict[str, List[str]] = collections.defaultdict(list) 92 | for sha in shas: 93 | for filepath in _files_for_sha(sha): 94 | if filepath in changed_files: 95 | shas_by_file[filepath].append(sha) 96 | 97 | return shas_by_file 98 | 99 | 100 | def _main(squash: bool, commit_args: List[str]) -> None: 101 | if not _is_in_repo(): 102 | sys.exit("error: not in git repo") 103 | 104 | possible_shas = _get_possible_shas() 105 | if not possible_shas: 106 | sys.exit("error: no commits ahead of upstream") 107 | 108 | changed_files, stage_first = _get_changed_files() 109 | if not changed_files: 110 | sys.exit("error: no changed files") 111 | 112 | shas_by_file = _shas_by_file(possible_shas, changed_files) 113 | matching_shas = set( 114 | sum( 115 | ( 116 | shas_by_file[filepath] 117 | for filepath in changed_files 118 | if filepath in shas_by_file 119 | ), 120 | [], 121 | ) 122 | ) 123 | 124 | if not matching_shas: 125 | sys.exit( 126 | "error: no commits changed: {}".format(", ".join(changed_files)) 127 | ) 128 | elif len(matching_shas) > 1: 129 | option_lines = [] 130 | print( 131 | "Which commit would you like to add your changes to? (ctrl-c to cancel)" 132 | ) 133 | # TODO: Put shas in committed date order? 134 | for sha in matching_shas: 135 | output = subprocess.check_output( 136 | ["git", "log", "--oneline", "-1", sha] 137 | ).decode() 138 | option_lines.append(output) 139 | 140 | try: 141 | selected_line = subprocess.check_output( 142 | ["fzy"], input="\n".join(sorted(option_lines)).encode() 143 | ).decode() 144 | except FileNotFoundError: 145 | sys.exit(f"{RED}error: 'fzy' was not found. Please install 'fzy' and make it available in your $PATH.\n" 146 | f"Learn more at: https://github.com/keith/git-pile/blob/main/README.md#manually{RESET}") 147 | except subprocess.CalledProcessError: 148 | sys.exit(1) 149 | 150 | selected_sha = selected_line.split(" ")[0] 151 | else: 152 | selected_sha = matching_shas.pop() 153 | 154 | if stage_first: 155 | _run_git_command(["add"] + changed_files) 156 | 157 | args = commit_args 158 | if squash: 159 | args.extend(["-m 'ignore, will be squashed'", "-s"]) 160 | 161 | code = subprocess.Popen(["git", "commit", "--quiet"] + args).wait() 162 | if code != 0: 163 | raise SystemExit("error: failed to commit, not updating PR") 164 | 165 | update_command = ["git", "updatepr", selected_sha] 166 | if squash: 167 | update_command.append("--squash") 168 | 169 | try: 170 | subprocess.check_call(update_command) 171 | except subprocess.CalledProcessError: 172 | sys.exit(1) 173 | 174 | 175 | if __name__ == "__main__": 176 | args, commit_args = _build_parser().parse_known_args() 177 | _main(args.squash, commit_args) 178 | -------------------------------------------------------------------------------- /bin/git-submitpr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | quiet_arg="--quiet" 6 | if [[ -n "${GIT_PILE_VERBOSE:-}" ]]; then 7 | set -x 8 | quiet_arg="" 9 | fi 10 | 11 | gitlab_mode_enabled=$(git config --default false --type=bool pile.gitlabModeEnabled) 12 | 13 | if [[ $gitlab_mode_enabled = true ]]; then 14 | origin_url=$(git remote get-url origin | sed 's/\.git$//') 15 | ssh_pattern="^git@(git\.)?([^:]+):(.+)$" # git@git.gitlab.companydomain.app:mobile/ios 16 | https_pattern="^https:\/\/(.+)$" # https://gitlab.companydomain.app/mobile/ios 17 | if [[ $origin_url =~ $ssh_pattern ]]; then 18 | gitlab_url="https://${BASH_REMATCH[2]}/${BASH_REMATCH[3]}/-/merge_requests/new" 19 | elif [[ $origin_url =~ $https_pattern ]]; then 20 | gitlab_url="https://${BASH_REMATCH[1]}/-/merge_requests/new" 21 | else 22 | echo "error: Cannot derive GitLab merge request URL from origin" 23 | exit 1 24 | fi 25 | else 26 | if ! command -v gh > /dev/null; then 27 | echo "error: missing gh, install here: https://cli.github.com" >&2 28 | exit 1 29 | fi 30 | fi 31 | 32 | commit_arg=HEAD 33 | if [[ $# -gt 0 ]]; then 34 | if [[ $1 != --* ]]; then 35 | commit_arg="$1" 36 | shift 37 | fi 38 | 39 | # TODO: Parse commit before or after onto 40 | if [[ ${1:-} == "--onto" ]]; then 41 | onto_ref="$2" 42 | shift 43 | shift 44 | fi 45 | 46 | if [[ ${1:-} == "--base" ]]; then 47 | base="$2" 48 | shift 49 | shift 50 | fi 51 | fi 52 | 53 | if [[ -n "${onto_ref:-}" && -n "${base:-}" ]]; then 54 | echo "error: cannot specify both --onto and --base" >&2 55 | exit 1 56 | fi 57 | 58 | commit="$(git rev-parse "$commit_arg")" 59 | upstream_ref="@{upstream}" 60 | branch_name="$(git pilebranchname "$commit")" 61 | 62 | if [[ -n "${onto_ref:-}" ]]; then 63 | base_branch_name="$(git pilebranchname "$onto_ref")" 64 | upstream_ref="$base_branch_name@{upstream}" 65 | elif [[ -n "${base:-}" ]]; then 66 | upstream_ref="$base@{upstream}" 67 | fi 68 | 69 | if git show-ref --verify $quiet_arg refs/heads/"$branch_name"; then 70 | echo "error: branch named '$branch_name' already exists, maybe you've already created the PR for this commit?" >&2 71 | exit 1 72 | fi 73 | 74 | branch_with_remote=$(git rev-parse --abbrev-ref --symbolic-full-name "$upstream_ref") 75 | remote_branch_name="${branch_with_remote#*/}" 76 | 77 | git branch --no-track "$branch_name" "$upstream_ref" 78 | 79 | worktree_dir="$(git pileworktreepath)" 80 | if [[ ! -d "$worktree_dir" ]]; then 81 | git worktree add $quiet_arg --force "$worktree_dir" "$branch_name" 82 | else 83 | # TODO: I've had the repo be in a bad state and maybe we need a cherry pick abort here, or on the cleanup instead 84 | if ! git -C "$worktree_dir" switch $quiet_arg "$branch_name"; then 85 | git branch -D "$branch_name" 86 | exit 1 87 | fi 88 | fi 89 | 90 | _detach_branch() { 91 | git -C "$worktree_dir" switch --detach $quiet_arg 92 | } 93 | 94 | trap _detach_branch EXIT 95 | 96 | if ! git -C "$worktree_dir" cherry-pick "$commit" >/dev/null; then 97 | # TODO: if you ctrl-c out of the merge tool, it doesn't return false it exists the script only hitting the trap, so the cherry pick is not aborted 98 | if git -C "$worktree_dir" mergetool; then 99 | if ! git -C "$worktree_dir" -c core.editor=true cherry-pick --continue; then 100 | git -C "$worktree_dir" cherry-pick --abort || true 101 | _detach_branch 102 | git branch -D "$branch_name" 103 | echo "error: cherry picking failed, maybe there wasn't a commit to cherry pick?" >&2 104 | exit 1 105 | fi 106 | else 107 | git -C "$worktree_dir" cherry-pick --abort 108 | _detach_branch 109 | git branch -D "$branch_name" 110 | exit 1 111 | fi 112 | fi 113 | 114 | native_open() { 115 | if command -v open >/dev/null; then 116 | open "$1" 117 | elif command -v xdg-open >/dev/null; then 118 | xdg-open "$1" 119 | else 120 | echo "PR is created at $1" 121 | fi 122 | } 123 | 124 | cleanup_remote_branch() { 125 | if [[ "$(git config --default false --type=bool pile.cleanupRemoteOnSubmitFailure)" != true ]]; then 126 | return 127 | fi 128 | 129 | echo "error: failed to create PR, deleting remote branch..." >&2 130 | git pilecleanupremotebranch "$branch_name" 131 | exit 1 132 | } 133 | 134 | gitlab_create_pr() { 135 | native_open "$gitlab_url?merge_request[source_branch]=$1&merge_request[target_branch]=$2" 136 | } 137 | 138 | error_file=$(mktemp) 139 | if git -C "$worktree_dir" remote get-url mine >/dev/null 2>&1; then 140 | if git -C "$worktree_dir" push --no-verify $quiet_arg --set-upstream mine "$branch_name"; then 141 | # TODO: 'origin' might not be the only option 142 | origin_url=$(git -C "$worktree_dir" remote get-url origin) 143 | # TODO: does gh not support -C either? 144 | pushd "$worktree_dir" >/dev/null 145 | if [[ $gitlab_mode_enabled = true ]]; then 146 | gitlab_create_pr "$branch_name" "$remote_branch_name" 147 | else 148 | if url=$(gh pr create --fill --repo "$origin_url" --base "$remote_branch_name" "${pr_args[@]:---}" | grep github.com); then 149 | native_open "$url" 150 | fi 151 | fi 152 | popd >/dev/null 153 | else 154 | echo "error: failed to create remote branch: $(cat "$error_file")" >&2 155 | _detach_branch 156 | git branch -D "$branch_name" 157 | exit 1 158 | fi 159 | elif git -C "$worktree_dir" push --no-verify $quiet_arg --set-upstream origin "$branch_name" 2> "$error_file"; then 160 | # TODO: does gh not support -C either? 161 | pushd "$worktree_dir" >/dev/null 162 | body_args=(--fill) 163 | if [[ -n ${GIT_PILE_USE_PR_TEMPLATE:-} && -f .github/pull_request_template.md ]]; then 164 | subject=$(git -C "$worktree_dir" show -s --format=%s HEAD) 165 | body=$(git -C "$worktree_dir" show -s --format=%b HEAD) 166 | if [[ -n "$body" ]]; then 167 | body=$(printf '%s\n\n%s' "$body" "$(cat .github/pull_request_template.md)") 168 | else 169 | body="$(cat .github/pull_request_template.md)" 170 | fi 171 | body_args=(--title "$subject" --body "$body") 172 | fi 173 | 174 | pr_args=() 175 | merge_arg="" 176 | for arg in "$@" 177 | do 178 | case "$arg" in 179 | --merge-squash) 180 | merge_arg="--squash" 181 | shift 182 | ;; 183 | --merge-rebase) 184 | merge_arg="--rebase" 185 | shift 186 | ;; 187 | --merge) 188 | merge_arg="--merge" 189 | shift 190 | ;; 191 | *) 192 | pr_args+=("$arg") 193 | shift 194 | ;; 195 | esac 196 | done 197 | 198 | if [[ $gitlab_mode_enabled = true ]]; then 199 | gitlab_create_pr "$branch_name" "$remote_branch_name" 200 | else 201 | if url=$(gh pr create "${body_args[@]}" --base "$remote_branch_name" "${pr_args[@]:---}" | grep github.com); then 202 | # TODO: should I set subject and body? 203 | if [[ -n "$merge_arg" ]] && ! gh pr merge "$url" --auto "$merge_arg"; then 204 | native_open "$url" 205 | echo "warning: failed to auto-merge PR with $merge_arg" >&2 206 | exit 1 207 | fi 208 | 209 | native_open "$url" 210 | else 211 | cleanup_remote_branch 212 | fi 213 | fi 214 | 215 | popd >/dev/null 216 | else 217 | echo "error: failed to create remote branch: $(cat "$error_file")" >&2 218 | _detach_branch 219 | git branch -D "$branch_name" 220 | exit 1 221 | fi 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-pile 2 | 3 | `git-pile` is a set of scripts for using a stacked-diff[^1] workflow 4 | with git & GitHub[^2]. There are a lot of different trade-offs for how 5 | this can work, `git-pile` chooses to be mostly not-magical at the cost 6 | of being best at handling multiple commits that _don't conflict_ with 7 | each other instead of chains of pull requests affecting the same code. 8 | This approach was conceived by [Dave 9 | Lee](https://github.com/kastiglione) and I while working at Lyft, you 10 | can read more about that 11 | [here](https://kastiglione.github.io/git/2020/09/11/git-stacked-commits.html). 12 | 13 | [^1]: [This](https://jg.gg/2018/09/29/stacked-diffs-versus-pull-requests) 14 | is a good explainer, or you can just read the [usage](#usage) 15 | examples. 16 | [^2]: These scripts could be extended to support other Git hosts that 17 | supported similar workflows without too much work. 18 | 19 | ## Benefits 20 | 21 | 1. Never think about branches again 22 | 2. Always test all of your changes integrated together on the repo's 23 | main branch, even if they are submitted as separate pull requests on 24 | GitHub 25 | 3. Avoid thrashing state such as file time stamps or build caches 26 | when switching between different work 27 | 28 | ## Usage 29 | 30 | ### git-submitpr 31 | 32 | The `git-submitpr` is the first script you run to interact with 33 | `git-pile`. It will submit a PR on GitHub with just the most recent 34 | commit from your "pile" of commits on your branch. It automatically uses 35 | your commit message to fill in your PR title and description: 36 | 37 | ```sh 38 | $ git checkout main # always do work on your main branch 39 | $ # do some work 40 | $ git add -A 41 | $ git commit -m "I made some changes" 42 | $ git submitpr 43 | ``` 44 | 45 | Once you submit a PR you are free to move on and start working on other 46 | changes while still on the main branch. 47 | 48 | #### Options 49 | 50 | - You can pass a different sha for submitting a PR for an older commit 51 | on the branch (by default `HEAD` is used). This is for the case where 52 | you forget to submit a PR for a commit, and then make a new commit on 53 | top of it. 54 | - All other options passed to `git submitpr` are passed through to the 55 | underlying `gh pr create` invocation 56 | - You can stack a PR using the `--onto` flag. For example: `git submitpr 57 | --onto head~2` 58 | - You can submit a PR targeting another base branch using the `--base` 59 | flag. For example: `git submitpr --base my-feature-branch` 60 | - If your GitHub repo supports auto-merge, you can pass 61 | `--merge-rebase`, `--merge-squash`, or `--merge` when creating the PR 62 | to enable auto-merge with the respective method. If enabling 63 | auto-merge fails for some reason, the PR is still submitted. 64 | 65 | ### git-updatepr 66 | 67 | `git-updatepr` allows you to add more changes to an existing PR. For 68 | example: 69 | 70 | ```sh 71 | $ git submitpr # Create the intitial PR 72 | $ # get some code review feedback 73 | $ # make more changes 74 | $ git add -A 75 | $ git commit -m "I fixed the code review issue" 76 | $ git updatepr abc123 # pass the sha of the local commit from the original the PR 77 | ``` 78 | 79 | This will push the new commit to the PR you created originally. 80 | 81 | #### Options 82 | 83 | - Pass `--squash` to squash the new commit into the initial commit on 84 | the PR, by default the new commit will be pushed directly. 85 | 86 | ### git-headpr 87 | 88 | `git-headpr` is similar to [`git-updatepr`](#git-updatepr) except it 89 | doesn't require you to have committed your changes manually, and it 90 | automatically updates the PR from the most recent commit in your pile, 91 | avoiding you having to grab the specific sha. For example: 92 | 93 | ```sh 94 | $ git submitpr # Create the intitial PR 95 | $ # get some code review feedback 96 | $ # make more changes 97 | $ git add -A 98 | $ git status 99 | ... some changes are shown 100 | $ git headpr 101 | ``` 102 | 103 | In this case `git-pile` will initiate a commit, and then run 104 | `git updatepr` with the most recent sha on your branch. This only works 105 | if you haven't made subsequent commits since the PR you want to update. 106 | 107 | #### Options 108 | 109 | - You can pass `--squash` to squash the new commit into the initial 110 | commit from the PR (in this case you will not be promoted for a commit 111 | message) 112 | - All other options are passed through to `git commit` 113 | 114 | ### git-absorb 115 | 116 | `git-absorb` is a more advanced version of [`git-headpr`](#git-headpr) 117 | copied from the idea of [`hg 118 | absorb`](https://gregoryszorc.com/blog/2018/11/05/absorbing-commit-changes-in-mercurial-4.8/) 119 | (but currently far less advanced). It intelligently chooses which commit 120 | your new changes should be added to based on which files you're changing 121 | and in which commits you changed them in previously. 122 | 123 | This is useful for when you have many commits in your pile, and you go 124 | back to make a change to a previous PR. For example: 125 | 126 | ```sh 127 | $ # change file1 128 | $ # commit + submitpr 129 | $ # change file2 130 | $ # commit + submitpr 131 | $ # go back and change file1 again 132 | $ git status 133 | ... shows file1 is changed 134 | $ git absorb 135 | ``` 136 | 137 | In this example `git absorb` will prompt you to commit, and then 138 | automatically run `git updatepr` updating your first commit that changed 139 | `file1`. It is functionally equivalent to: 140 | 141 | ```sh 142 | $ git commit -m "..." 143 | $ git updatepr sha123 # the sha from the first change 144 | ``` 145 | 146 | In the case that multiple commits in your pile touched the same files, 147 | `git absorb` will prompt you with a fuzzy finder to choose which PR to 148 | update. 149 | 150 | If you have staged files, only those will be included in the commit 151 | (like normal), if you don't have any staged files `git absorb` will `git 152 | add` _all_ your currently changed files before committing. 153 | 154 | #### Options 155 | 156 | - You can pass `--squash` to squash the new commit into the initial 157 | commit from the PR (in this case you will not be promoted for a commit 158 | message) 159 | - All other options are passed through to `git commit` 160 | 161 | ### git-rebasepr 162 | 163 | `git-rebasepr` rebases the PR for a given sha. This is useful in the 164 | case that your changes were functionally dependent so CI on your PR was 165 | failing until something else merged, or just in the case your PR is very 166 | old and you want to rebase it to re-run CI against the new state of the 167 | repo. 168 | 169 | Example: 170 | 171 | ```sh 172 | $ git rebasepr abc123 # the sha of the PR you want to rebase 173 | ``` 174 | 175 | ## Installation 176 | 177 | ### On macOS with [homebrew](https://brew.sh) 178 | 179 | ``` 180 | brew install keith/formulae/git-pile 181 | ``` 182 | 183 | ### Manually 184 | 185 | 1. Add this repo's `bin` directory to your `PATH` 186 | 2. Install [gh](https://cli.github.com/) 187 | 3. Install [fzy](https://github.com/jhawthorn/fzy) and `python3` 188 | (required for [`git-absorb`](#git-absorb)) 189 | 190 | ## Configuration 191 | 192 | ### Required 193 | 194 | - Run `gh auth status` to make sure you have a valid login with `gh`, 195 | otherwise you'll need to sign in with it, run `gh auth` for 196 | instructions. 197 | 198 | ### Recommended 199 | 200 | - Run `git config --global rerere.enabled true` to save conflict 201 | resolution outcomes so that in the case that you hit conflicts you 202 | only have to resolve them once. If you enable this setting you also 203 | need to run `git config --global rerere.autoupdate true` otherwise 204 | previous resolutions will not be automatically staged. 205 | - Run `git config --global pull.rebase true` to use the rebase strategy 206 | when pulling from the remote. This way when you run `git pull` you 207 | will be able to easily skip commits with `git rebase --skip` that were 208 | landed upstream, but have local conflicts in your pile. 209 | - Run `git config --global advice.skippedCherryPicks false` to disable 210 | `git` telling you that some local commits where ignored when you `git 211 | pull`, this is the expected behavior of commits disappearing from your 212 | local pile after they're merged on GitHub. 213 | - Configure git to stop you from accidentally pushing to your 214 | main branch with `git config --global branch.main.pushRemote NOPE`. To 215 | allow pushing to the main branch for specific repos you can set 216 | config just for that repo with `git config branch.main.pushRemote 217 | origin` 218 | 219 | ### Optional 220 | 221 | - Set `GIT_PILE_PREFIX` in your shell environment if you'd 222 | like to use a consistent prefix in the underlying branch names 223 | `git-pile` creates. For example `export GIT_PILE_PREFIX=ks/`. Note if 224 | you change this after using `git-pile` to create a PR, your PRs 225 | created before setting the prefix will not be updatable with the other 226 | commands. 227 | - Set `GIT_PILE_USE_PR_TEMPLATE` in your shell environment if you'd like 228 | `git-pile` to attempt to prefill the description of your PR with the [PR 229 | template][template] file if it exists. 230 | - Run `git config --global pile.cleanupRemoteOnSubmitFailure true` to 231 | automatically delete remote branches that mirror your local branch 232 | when submitting the PR fails. This makes it easier to run `git 233 | submitpr` again in the case you had a networking issue that causes the 234 | submission to fail. This is off by default to avoid potentially 235 | deleting a remote branch that somehow has commits that aren't on the 236 | local branch. 237 | 238 | [template]: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository 239 | 240 | ### GitLab support 241 | 242 | - You can use `git-pile` with GitLab. Enable GitLab mode by running 243 | `git config pile.gitlabModeEnabled true`. 244 | 245 | ## Advanced usage 246 | 247 | ### Squash and merge 248 | 249 | It's best to use `git-pile` with the squash-and-merge GitHub merge 250 | strategy. This is because `git-pile` squashes all commits that you push 251 | to a PR into one on your main branch, as is traditional with stacked 252 | diff workflows where each commit is an independent atomic change. 253 | 254 | In the case where this doesn't work for you, either by accident or when 255 | contributing to an open source repo that uses a different merge strategy 256 | there are a few things to note: 257 | 258 | - When you `git pull` your commit may not disappear cleanly. In this 259 | case I often use `git rebase --skip` when I know that the upstream 260 | should be the source of truth for a commit 261 | 262 | ### Editing on GitHub 263 | 264 | In some cases you receive code review comments that you want to commit 265 | directly in the GitHub UI, if you do this your local commit becomes out 266 | of sync with the underlying branch that was created. In this case there 267 | are 2 important things to note: 268 | 269 | - When you `git pull` you might have conflicts with your local commit, 270 | and it won't disappear cleanly. In this case I often `git rebase 271 | --skip` and accept the remote commit instead. 272 | - If you want to push more changes to the same PR locally `git updatepr` 273 | will identify that changes were made on the upstream branch, and 274 | confirm that you want to pull them before pushing your own changes. 275 | 276 | ### Conflicting changes 277 | 278 | Using `git-pile` is easier in the case your changes do not conflict, but 279 | `git-pile` still does its best to handle resolving conflicts in the case 280 | they arise. For example if you submit 2 PRs that have conflicting 281 | changes, when you run `git submitpr` conflicts will arise when the 282 | commit is being cherry picked. In this case you must resolve the 283 | conflicts and run `git cherry-pick --continue`. Then when you are 284 | merging the PRs on GitHub, likely you will have to rebase one of the PRs 285 | after the first one merges to resolve the conflicts yet again. In this 286 | case I often run `git rebasepr` locally after one of the PRs merges to 287 | resolve the conflicts. If you have `rerere.enabled` set globally in your 288 | `git` config, you may only have to resolve the conflicts once. 289 | 290 | ### Dropping changes 291 | 292 | Sometimes you might submit a PR, and realize it wasn't the right 293 | approach. Or you might want to submit multiple PRs touching related 294 | areas just for testing CI, or showing an example. In this case you might 295 | not want these commits sitting around on your pile forever. To avoid 296 | this I often "drop" commits from my pile, either by using `git rebase 297 | -i` and deleting the lines from the file, or by using [this 298 | script](https://github.com/keith/dotfiles/blob/2ae59b8f2afbb2a2cea2b55ef1b37da55bd5c1d3/bin/git-droplast). 299 | Be careful not to drop any un-submitted work when doing this. 300 | 301 | ### Stacked PRs 302 | 303 | `git-pile` supports basic PR stacking by passing the `--onto SHA` flag 304 | to `git submitpr`. This creates your PR targeting the underlying branch 305 | from the commit you pass. This assumes your other commit already has a 306 | PR. Unlike some other tools `git-pile` does not handle the merging and 307 | resolution of these PRs. When you merge the first PR in your stack, 308 | GitHub will automatically re-target your second PR to the correct 309 | branch. Unfortunately it will leave the initial commit in the branch, 310 | which means you have to `git rebasepr` your second commit, to make 311 | GitHub correctly reflect the changes in the PR. 312 | 313 | ## Under the hood 314 | 315 | As stated above one of the advantages of `git-pile` over other stacked 316 | diff workflows is relative simplicity. Here's how `git-pile` works when 317 | you run `git submitpr`: 318 | 319 | 1. It creates a [`git worktree`](https://git-scm.com/docs/git-worktree) 320 | in `~/.cache/git-pile` for the current repository 321 | 2. It derives a branch name from your commit message's title 322 | 3. It branches off the upstream of your currently checked out branch 323 | 4. It checks out the new branch in the worktree, and cherry picks your 324 | commit onto the branch 325 | 5. It pushes the branch to the remote 326 | 6. It submits a PR using `gh pr create` 327 | 328 | While this is a lot of steps, the nice part of this is that if you hit 329 | an issue with `git-pile`, or want fall back to a workflow you're more 330 | comfortable with, you can `git switch` to the underlying branch that 331 | `git-pile` created, and use normal `git` as normal. You can even swap 332 | between the `git-pile` workflow and not, as long as you're aware of the 333 | potential for introducing conflicts you'll have to resolve later. 334 | 335 | Once the steps above have been done, all other commands like 336 | `git updatepr` follow steps similar to: 337 | 338 | 1. Checkout the previously created branch in the worktree 339 | 2. Cherry pick the new commit to the branch, squashing if requested (in 340 | the case of conflicts, you resolve them as usual and run `git 341 | cherry-pick --continue`) 342 | 3. Push the new branch state to the remote 343 | 4. Squash the new commit into the original commit on your main branch, 344 | treating it as a single change. 345 | --------------------------------------------------------------------------------