├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── git-branchstack ├── git-branchstack-list-branches ├── git-branchstack-pick ├── gitbranchstack ├── __init__.py └── main.py ├── setup.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | /git_branchstack.egg-info/ 2 | /build/ 3 | /dist/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## [0.2.0] - 2022-01-09 6 | - BREAKING: option `--trim-subject` has been dropped and is the default 7 | behavior. New option `--keep-tags` restores the old behavior. Consequently, 8 | the meaning of `+` inside topic tags has been inverted. 9 | - Preserve committer date in created commits. This means we create 10 | deterministic commit IDs, which makes it easier to reuse commits, and 11 | creates fewer loose objects. 12 | 13 | ## [0.1.0] - 2021-08-29 14 | - BREAKING `git-branchless` was renamed to `git-branchstack` (#1). 15 | - `git-branchstack-pick` replaces a "refs/" prefix with "gitref/", making it 16 | easier to cherry-pick remote non-branch refs without introducing ambiguous 17 | refs. 18 | 19 | ## [0.0.6] - 2021-06-03 20 | - `git-branchless-pick` now prefers `$GIT_SEQUENCE_EDITOR` over `git var $EDITOR` 21 | for editing the rebase-todo list. 22 | - `git-branchless-pick` now supports empty base commits, so `..some-branch` 23 | means: pick all commits on `some-branch` minus the commits already in `HEAD`. 24 | - `git-branchless-pick ..some/branch` will no longer trim the `some/` prefix, 25 | unless `some` is a valid Git remote. 26 | 27 | ## [0.0.5] - 2021-04-24 28 | - First release on PyPI. 29 | - Specify dependencies with a `+` prefix, like `[child:+parent]`, to include 30 | commits from `parent` and trim their subject tags. 31 | - `git-branchless-pick` no longer pulls in new commits from `@{upstream}`. 32 | - Fix subject computation for conflict hint commits without topic prefix 33 | 34 | ## [0.0.4] - 2021-03-07 35 | - BREAKING: `git-branchless-pick` takes a `..`-range instead of a single commit. 36 | - Branches are no longer based on @{upstream} but on `git merge-base @{u} HEAD` 37 | - Similarly, `git-branchless-pick` will only not add new commits from @{upstream} 38 | - Support multline subjects 39 | 40 | ## [0.0.3] - 2021-02-03 41 | - BREAKING: the latest version of git-revise is now required, see README 42 | - On conflict, show commits that are likely missing as dependencies 43 | - Allow passing a custom range with -r to override @{upstream}..HEAD 44 | - Allow dropping topic tags from subject with -t/--trim-subject 45 | - Fixed a case of mistakenly refusing to overwrite branches after 46 | cancelling a previous run (usually on conflict) 47 | - git-branchless-pick inserts new commits in the rebase-todo list 48 | immediately after dropped commits, instead of before them 49 | 50 | ## [0.0.2] - 2021-01-21 51 | - Fix error when previously generated branch was deleted 52 | - Fix cache of previously generated branches being cleared too eagerly 53 | - Fix git-branchless-pick inserting new cherry-picks at the beginning of the 54 | todo list, instead of at the end. 55 | - Fix cases when a Git ref with the same name as a branch exists 56 | - Show more explicit errors on invalid usage 57 | 58 | ## [0.0.1] - 2021-01-17 59 | - Initial release 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Johannes Altmanninger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all 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, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git branchstack 2 | 3 | **NOTE:** This tool has some limitations, some due to incomplete design and 4 | others due to practical limitations in current Git implementations. I have 5 | switched to using [jj](https://github.com/martinvonz/jj) instead, which 6 | provides a cleaner and more powerful solution. 7 | 8 | [![PyPi](https://img.shields.io/pypi/v/git-branchstack.svg)](https://pypi.org/project/git-branchstack) 9 | 10 | ## Motivation 11 | 12 | When I am working on multiple changes to a [Git] repository, I usually want to 13 | combine all of my changes in a single branch, but send them upstream in small, 14 | reviewable chunks. As stated in [the related articles](#related-articles) 15 | one advantage is that you can base new work on previous changes, and test 16 | them in combination. 17 | 18 | Git already supports this workflow via [git format-patch] and [git send-email], 19 | however, many projects prefer to receive patches via pull requests. To make 20 | proposed changes easy to review, you'll want to submit a separate pull request 21 | for each independent change on your worktree's branch. This means that you 22 | want to create branches containing those independent changes and nothing else. 23 | 24 | `git branchstack` creates the desired branches without requiring you to switch 25 | back and forth between branches (and invalidating builds). This allows you 26 | to submit small pull requests, while enjoying the benefits of a branchless 27 | workflow. After making any changes to your worktree's branch you can easily 28 | update the generated branches: just re-run `git branchstack`. 29 | 30 | ## Installation 31 | 32 | ### Via pip 33 | 34 | ```sh 35 | $ pip install --user git-branchstack 36 | ``` 37 | 38 | Instead of the last command you can also run [`./git-branchstack`](./git-branchstack) directly, provided you have `git-revise>=0.7.0`. 39 | 40 | ### Via [pipx](https://pypa.github.io/pipx/) 41 | 42 | Use this instead to avoid breakage when your Python installation is upgraded. 43 | 44 | ```sh 45 | $ pipx install git-branchstack 46 | ``` 47 | 48 | ## Usage 49 | 50 | Create some commits with commit messages starting with a topic tag `[...]`. 51 | The topic name, ``, inside the square bracket tag markers `[]`, 52 | must be an unused valid branch name. Then run `git branchstack` to create 53 | the branch `` with the given commits. 54 | 55 | For example, if you have created a commit history like 56 | 57 | $ git log --graph --oneline 58 | * 9629a6c (HEAD -> local-branch) [some-unrelated-fix] Unrelated fix 59 | * e764f47 [my-awesome-feature] Some more work on feature 60 | * a9a811f [my-awesome-feature] Initial support for feature 61 | * 28fcf9c Local commit without topic tag 62 | * 5fb0776 (master) Initial commit 63 | 64 | Then this command will (re)create two branches: 65 | 66 | $ git branchstack 67 | $ git log --graph --oneline --all 68 | * 9629a6c (HEAD -> local-branch) [some-unrelated-fix] Unrelated fix 69 | * e764f47 [my-awesome-feature] Some more work on feature 70 | * a9a811f [my-awesome-feature] Initial support for feature 71 | * 28fcf9c Local commit without topic tag 72 | | * 7d4d166 (my-awesome-feature) Some more work on feature 73 | | * fb0941f Initial support for feature 74 | |/ 75 | | * 1a37fd0 (some-unrelated-fix) Unrelated fix 76 | |/ 77 | * 5fb0776 (master) Initial commit 78 | 79 | By default, `git branchstack` looks only at commits in the range 80 | `@{upstream}..HEAD`. It ignores commits whose subject does not start with 81 | a topic tag. 82 | 83 | Created branches are based on the common ancestor of your branch and the 84 | upstream branch, that is, `git merge-base @{upstream} HEAD`. 85 | 86 | To avoid conflicts, you can specify dependencies between branches. 87 | For example use `[child:parent1:parent2]` to base `child` off both `parent1` 88 | and `parent2`. The order of parents does not matter: the one that occurs 89 | first in the commit log will be added first. 90 | 91 | Pass `--keep-tags` to mark dependency commits by keeping the commits' 92 | topic tags. Use `keep-tags=all` to keep all topic tags. To only keep topic 93 | tags of select dependencies, prefix them with the `+` character (like 94 | `[child:+parent]`). 95 | 96 | If a commit cannot be applied cleanly, `git branchstack` will show topics 97 | that would avoid the conflict if added as dependencies. You can either 98 | add the missing dependencies, or resolve the conflict in your editor. You 99 | can tell Git to remember your conflict resolution by enabling `git rerere` 100 | (use `git config rerere.enabled true; git config rerere.autoUpdate true`). 101 | 102 | Instead of the default topic tag delimiters (`[` and `]`), you can 103 | set Git configuration values `branchstack.subjectPrefixPrefix` and 104 | `branchstack.subjectPrefixSuffix`, respectively. 105 | 106 | ## Integrating Commits from Other Branches 107 | 108 | You can use [git-branchstack-pick](./git-branchstack-pick) to integrate 109 | other commit ranges into your branch: 110 | 111 | ```sh 112 | $ git branchstack-pick ..some-branch 113 | ``` 114 | 115 | This behaves like `git rebase -i` except it prefills the rebase-todo list to 116 | cherry-pick all missing commits from `some-branch`, prefixing their commit 117 | subjects with `[some-branch]`. Old commits with such a subject are dropped, 118 | so this allows you to quickly update to the latest upstream version of a 119 | ref that has been force-pushed. 120 | 121 | Here's how you would use this to cherry-pick GitHub pull requests: 122 | 123 | ```sh 124 | $ git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr-*' 125 | $ git fetch origin 126 | $ git branchstack-pick ..origin/pr-123 127 | ``` 128 | 129 | ## Tips 130 | 131 | - You can use [git revise] to efficiently modify your commit messages 132 | to contain the `[]` tags. This command lets you edit all commit 133 | messages in `@{upstream}..HEAD`. 134 | 135 | ```sh 136 | $ git revise --interactive --edit 137 | ``` 138 | 139 | Like `git revise`, you can use `git branchstack` during an interactive rebase. 140 | 141 | - [`git-autofixup`](https://github.com/torbiak/git-autofixup/) can eliminate 142 | some of the busywork involved in creating fixup commits. 143 | 144 | ## Related Articles 145 | 146 | - In [Stacked Diffs Versus Pull Requests], Jackson Gabbard 147 | describes the advantages of a patch-based workflow (using [Phabricator]) 148 | over the one-branch-per-reviewable-change model; `git branchstack` can be used 149 | to implement the first workflow, even when you have to use pull-requests. 150 | 151 | - In [My unorthodox, branchless git workflow], Drew 152 | DeVault explains some advantages of a similar workflow. 153 | 154 | ## Peer Projects 155 | 156 | While `git branchstack` only offers one command and relies on standard Git 157 | tools for everything else, there are some tools that offer a more comprehensive 158 | set of commands to achieve a similar workflow: 159 | 160 | - [Stacked Git](https://stacked-git.github.io/) 161 | - [git ps](https://github.com/uptech/git-ps) 162 | - [gh-stack](https://github.com/timothyandrew/gh-stack) 163 | - [git machete](https://github.com/VirtusLab/git-machete) 164 | - [git-stack](https://github.com/epage/git-stack) 165 | - [depot-tools](https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html) 166 | 167 | Unlike its peers, `git branchstack` never modifies any worktree files, 168 | since it uses `git revise` internally. This makes it faster, and avoids 169 | invalidating builds. 170 | 171 | ## Contributing 172 | 173 | Submit feedback at or to the 174 | [public mailing list](https://lists.sr.ht/~krobelus/git-branchless) by 175 | sending email to . 176 | 177 | [Git]: 178 | [git revise]: 179 | [git format-patch]: 180 | [git send-email]: 181 | [Stacked Diffs Versus Pull Requests]: 182 | [My unorthodox, branchless git workflow]: 183 | [Phabricator]: 184 | -------------------------------------------------------------------------------- /git-branchstack: -------------------------------------------------------------------------------- 1 | gitbranchstack/main.py -------------------------------------------------------------------------------- /git-branchstack-list-branches: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Prints all branches that are cooking. 3 | 4 | prefix=$(git config branchstack.subjectPrefixPrefix || echo [) 5 | suffix=$(git config branchstack.subjectPrefixSuffix || echo ]) 6 | 7 | upstream=@{upstream} 8 | git log "${@:-$upstream..}" --format=%s | 9 | prefix=$prefix suffix=$suffix \ 10 | perl -ne 'use Env; print "$1\n" if m{^\Q$prefix\E([\w/-]+)[\w/:-]*\Q$suffix }' | 11 | uniq 12 | -------------------------------------------------------------------------------- /git-branchstack-pick: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | NAME="git ${0##*/git-}" 4 | usage() { 5 | echo "\ 6 | usage: $NAME .. [...] 7 | $NAME .. [...] 8 | 9 | Start a new interactive rebase, with these steps planned: 10 | - drop commits whose subjects start with \"[] \" 11 | - cherry-pick the given commit range and prefixes subjects with \"[] \" 12 | 13 | where is but with one leading path component removed. 14 | For example, can look like /. 15 | 16 | Arguments beyond the first are passed on to git-rebase(1)." && exit 1 17 | } 18 | 19 | if [ -z "$1" ] || [ "$1" = "-h" ]; then 20 | usage 21 | fi 22 | range=$1 23 | shift 24 | 25 | if ! printf %s "$range" | grep -qF ..; then 26 | echo "$NAME: argument must be a valid 'a..b' range: '$range'" 27 | exit 1 28 | fi 29 | 30 | remote_branch=${range#*..} # Right part of range. 31 | topic=${remote_branch} 32 | if [ "${remote_branch#refs/}" != "${remote_branch}" ]; then 33 | topic=gitref/${remote_branch#refs/} # Drop "refs/" to avoid ambiguous ref. 34 | elif git remote | grep -qxF -- "${remote_branch%%/*}"; then 35 | topic=${remote_branch#*/} # Drop remote. 36 | fi 37 | base_commit=${range%%..*} # Left part of range. 38 | base_commit=${base_commit:-$remote_branch} # This is good enough if the remote is not stale. 39 | 40 | prefix=$(git config branchstack.subjectPrefixPrefix || echo [) 41 | suffix=$(git config branchstack.subjectPrefixSuffix || echo ]) 42 | 43 | pick=pick 44 | drop=drop 45 | exec=exec 46 | if [ "$(git config rebase.abbreviateCommands)" = true ]; then 47 | pick=p 48 | drop=d 49 | exec=x 50 | fi 51 | 52 | cherries=$( 53 | git log --reverse --format="$pick %h $prefix${topic}$suffix %s" "$range" 54 | ) 55 | if ! [ "$cherries" ]; then 56 | echo "$NAME: nothing to cherry-pick from $range" 57 | exit 0 58 | fi 59 | 60 | todo=$(IFS=' 61 | ' 62 | for cherry in $cherries 63 | do 64 | printf "%s\n$exec %s\n" \ 65 | "$cherry" \ 66 | "GIT_EDITOR='perl -pi -e \"s{^}{$prefix${topic}$suffix } if $. == 1\"' git commit --amend --allow-empty" 67 | done 68 | ) 69 | todo=$todo \ 70 | editor=${GIT_SEQUENCE_EDITOR:-$(git var GIT_EDITOR)} \ 71 | topic=$topic prefix=$prefix suffix=$suffix \ 72 | pick=$pick drop=$drop \ 73 | GIT_SEQUENCE_EDITOR=' 74 | perl -pi -e '\'' 75 | use Env; 76 | if (m/^$pick \S+ \Q$prefix$topic\E(?::[^ ]+?)?\Q$suffix /) { 77 | s/^$pick/$drop/; 78 | $dropped_any = 1; 79 | } elsif ($todo and ($dropped_any or m/^$/)) { 80 | $_ = $todo . "\n" . $_; 81 | $todo = ""; 82 | } 83 | END { exec "$editor .git/rebase-merge/git-rebase-todo"; } 84 | '\''' git rebase -i --no-autosquash "$(git merge-base "$base_commit" HEAD)" "$@" 85 | -------------------------------------------------------------------------------- /gitbranchstack/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | -------------------------------------------------------------------------------- /gitbranchstack/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import sys 6 | from typing import Dict, Optional, List, Set, Tuple 7 | from pathlib import Path 8 | from subprocess import CalledProcessError 9 | 10 | import gitrevise 11 | from gitrevise import merge, utils 12 | from gitrevise.utils import EditorError 13 | from gitrevise.odb import Blob, Repository 14 | from gitrevise.merge import rebase, MergeConflict 15 | 16 | USAGE = """\ 17 | Create branches for commits in @{upstream}..HEAD if their commit message 18 | subject starts with [] where is the desired branch name. 19 | """ 20 | 21 | SUBJECT_PREFIX_PREFIX = b"[" 22 | SUBJECT_PREFIX_SUFFIX = b"]" 23 | 24 | CommitEntries = List[Tuple[str, str, str]] 25 | 26 | TrimSubject = bool 27 | Dependency = Tuple[str, TrimSubject] 28 | Dependencies = Dict[str, Dependency] 29 | 30 | def parse_log( 31 | repo, prefix_prefix, prefix_suffix, *args 32 | ) -> Tuple[CommitEntries, Dependencies]: 33 | commit_entries = [] 34 | dependency_graph: Dependencies = {} 35 | if "--reverse" not in args: 36 | dependency_graph = None 37 | include_others = "--reverse" not in args 38 | patches = repo.git("log", "-z", "--format=%H %B", *args).decode().split("\x00") 39 | for entry in patches: 40 | tmp = entry.split(maxsplit=1) 41 | if len(tmp) != 2: 42 | continue 43 | commit, message = tmp 44 | raw_subject = message.split("\n\n", maxsplit=1)[0].strip() 45 | words = raw_subject.split(maxsplit=1) 46 | if len(words) < 2: 47 | if include_others: 48 | commit_entries += [(commit, None, raw_subject)] 49 | continue 50 | prefix, subject = words 51 | if not prefix.startswith(prefix_prefix) or not prefix.endswith(prefix_suffix): 52 | if include_others: 53 | commit_entries += [(commit, None, raw_subject)] 54 | continue 55 | 56 | prefix = prefix[len(prefix_prefix) : -len(prefix_suffix)] 57 | topic_with_parents = prefix.split(":") 58 | topic = topic_with_parents[0] 59 | parent_topics = [parse_parent_topic(t) for t in topic_with_parents[1:] if t] 60 | 61 | if not topic: 62 | if include_others: 63 | commit_entries += [(commit, "", subject)] 64 | continue 65 | 66 | commit_entries += [(commit, topic, subject)] 67 | 68 | if dependency_graph is not None: 69 | if topic not in dependency_graph: 70 | dependency_graph[topic] = {} 71 | if parent_topics: 72 | dependency_graph[topic].update(parent_topics) 73 | 74 | return commit_entries, dependency_graph 75 | 76 | def parse_parent_topic(topic: str) -> Dependency: 77 | keep_tag = False 78 | if topic.startswith("+"): 79 | topic = topic[len("+") :] 80 | keep_tag = True 81 | return (topic, keep_tag) 82 | 83 | def transitive_dependencies(depgraph: Dependencies, node: Dependency) -> Dependencies: 84 | visited: Dependencies = {} 85 | transitive_dependencies_rec(depgraph, node, visited) 86 | return visited 87 | 88 | def transitive_dependencies_rec( 89 | depgraph: Dependencies, node: Dependency, visited: Dependencies 90 | ) -> None: 91 | name, keep_tag = node 92 | if name in visited: 93 | return 94 | visited[name] = keep_tag 95 | if name in depgraph: 96 | for x in depgraph[name].items(): 97 | transitive_dependencies_rec(depgraph, x, visited) 98 | 99 | class BranchWasModifiedError(Exception): 100 | pass 101 | 102 | class InvalidRangeError(Exception): 103 | pass 104 | 105 | class TopicNotFoundError(Exception): 106 | pass 107 | 108 | def validate_cache(repo, topic_set, force): 109 | cache_path = repo.gitdir / "branchstack-cache" 110 | if not os.path.exists(cache_path): 111 | return 112 | cached_shas = [ 113 | (w[0], w[1]) 114 | for w in map( 115 | lambda line: line.split(), cache_path.read_bytes().decode().splitlines() 116 | ) 117 | ] 118 | existing_branches = {} 119 | refs = repo.git( 120 | "for-each-ref", 121 | "--format", 122 | "%(refname:short) %(objectname)", 123 | *[f"refs/heads/{t[0]}" for t in cached_shas], 124 | ) 125 | for line in refs.decode().splitlines(): 126 | refname, sha = line.split(" ", maxsplit=1) 127 | existing_branches[refname] = sha 128 | for topic, cached_sha in cached_shas: 129 | if topic not in existing_branches: 130 | continue 131 | if topic not in topic_set: # The user did not ask to create this branch. 132 | continue 133 | current_sha = existing_branches[topic] 134 | if current_sha == cached_sha: 135 | continue 136 | if force: 137 | print(f"Will overwrite modified branch {topic}") 138 | else: 139 | raise BranchWasModifiedError(topic) 140 | 141 | def update_cache(repo, topics): 142 | cache_path = repo.gitdir / "branchstack-cache" 143 | mode = "rb+" if cache_path.exists() else "wb+" 144 | with open(cache_path, mode) as f: 145 | cached_shas = { 146 | w[0]: w[1] for w in map(lambda line: line.split(), f.read().decode().splitlines()) 147 | } 148 | new_topics = cached_shas 149 | for t in topics: 150 | if topics[t] is not None: 151 | new_topics[t] = topics[t] 152 | new_content = "" 153 | for topic in new_topics: 154 | sha = new_topics[topic] 155 | if sha is not None: 156 | new_content += f"{topic} {sha}{os.linesep}" 157 | f.seek(0) 158 | f.truncate() 159 | f.write(new_content.encode()) 160 | 161 | def trimmed_message(subject: str, message: bytes) -> str: 162 | body = b"\n".join(message.split(b"\n\n", maxsplit=1)[1:]) 163 | if body: 164 | return subject.encode() + b"\n\n" + body 165 | return subject.encode() 166 | 167 | def create_branches( 168 | repo, 169 | current_branch, 170 | base_commit, 171 | tip="HEAD", 172 | branches=None, 173 | force=False, 174 | keep_tags=None, 175 | ) -> None: 176 | prefix_prefix = repo.config( 177 | "branchstack.subjectPrefixPrefix", 178 | default=SUBJECT_PREFIX_PREFIX, 179 | ).decode() 180 | prefix_suffix = repo.config( 181 | "branchstack.subjectPrefixSuffix", 182 | default=SUBJECT_PREFIX_SUFFIX, 183 | ).decode() 184 | commit_entries, dependency_graph = parse_log( 185 | repo, prefix_prefix, prefix_suffix, f"{base_commit}..{tip}", "--reverse" 186 | ) 187 | def by_first_commit_on_topic(commit_entry): 188 | (commit, topic, subject) = commit_entry 189 | for i in range(len(commit_entries)): 190 | if commit_entries[i][1] == topic: 191 | return i 192 | return -1 193 | commit_entries.sort(key=by_first_commit_on_topic) 194 | topics = {commit_entry[1]: None for commit_entry in commit_entries} 195 | all_topics = set(topics) 196 | topic_set = all_topics 197 | 198 | if branches: 199 | for topic in branches: 200 | if topic not in all_topics: 201 | raise TopicNotFoundError(topic, base_commit, tip) 202 | topic_set = set() 203 | for topic in branches: 204 | topic_set.add(topic) 205 | topics = {t: None for t in topics if t in topic_set} 206 | 207 | for child in topics: 208 | for parent in dependency_graph[child]: 209 | if parent not in all_topics: 210 | print(f"Warning: topic '{child}' depends on missing topic '{parent}'.") 211 | 212 | assert current_branch is None or current_branch not in set( 213 | topics 214 | ), f"Refusing to overwrite current branch {current_branch}" 215 | 216 | base_commit_id = repo.git("rev-parse", base_commit).decode() 217 | 218 | validate_cache(repo, topic_set, force) 219 | try: 220 | for topic in topics: 221 | create_branch( 222 | repo, 223 | prefix_prefix, 224 | prefix_suffix, 225 | keep_tags, 226 | base_commit_id, 227 | commit_entries, 228 | topics, 229 | dependency_graph, 230 | topic, 231 | ) 232 | finally: 233 | update_cache(repo, topics) 234 | 235 | for topic in topics: 236 | print(topic) 237 | for line in ( 238 | repo.git("log", f"{base_commit}..refs/heads/{topic}", "--oneline") 239 | .decode() 240 | .splitlines() 241 | ): 242 | print("\t", line) 243 | 244 | def create_branch( 245 | repo, 246 | prefix_prefix, 247 | prefix_suffix, 248 | keep_tags, 249 | base_commit_id, 250 | commit_entries, 251 | topics, 252 | dependency_graph, 253 | topic, 254 | ): 255 | head = repo.get_commit(base_commit_id) 256 | deps = transitive_dependencies(dependency_graph, (topic, False)) 257 | for commit, t, subject in commit_entries: 258 | if t not in deps: 259 | continue 260 | keep_tag = deps[t] 261 | patch = repo.get_commit(commit) 262 | def on_conflict(path): 263 | """ 264 | Some commit in "base_commit..commit~" must have touched the 265 | path as well, but is not among our dependencies. 266 | """ 267 | print("Missing dependency on one of the commits below?") 268 | log, _ = parse_log( 269 | repo, 270 | prefix_prefix, 271 | prefix_suffix, 272 | f"{base_commit_id}..{commit}~", 273 | "--", 274 | path, 275 | ) 276 | for id, topic, subject in log: 277 | if topic not in deps: 278 | prefix = ( 279 | "" 280 | if topic is None 281 | else f"{prefix_prefix}{topic}{prefix_suffix} " 282 | ) 283 | print(f"\t{id[:7]} {prefix}{subject}") 284 | global ON_CONFLICT 285 | ON_CONFLICT = on_conflict 286 | head = rebase(patch, head) 287 | message = head.message 288 | keep_tag = ( 289 | keep_tag 290 | or (keep_tags == "dependencies" and t != topic) 291 | or keep_tags == "all" 292 | ) 293 | if not keep_tag: 294 | message = trimmed_message(subject, patch.message) 295 | head = repo.new_commit( 296 | message=message, 297 | tree=head.tree(), 298 | parents=head.parents(), 299 | author=head.author, 300 | committer=patch.committer, # preserve original committer and timestamp 301 | ) 302 | topic_fqn = f"refs/heads/{topic}" 303 | if not repo.git("branch", "--list", topic): 304 | repo.git("branch", topic, base_commit_id) 305 | topic_ref = repo.get_commit_ref(topic_fqn) 306 | 307 | if head.oid != topic_ref.target.oid: 308 | topic_oid = topic_ref.target.oid 309 | print(f"Updating {topic_ref.name} ({topic_oid} => {head.oid})") 310 | topic_ref.update(head, "git-branchstack rewrite") 311 | 312 | topics[topic] = topic_ref.target.oid 313 | 314 | ON_CONFLICT = None 315 | 316 | def override_merge_blobs( 317 | path: Path, 318 | labels: Tuple[str, str, str], 319 | current: Blob, 320 | base: Optional[Blob], 321 | other: Blob, 322 | ) -> Blob: 323 | repo = current.repo 324 | 325 | tmpdir = repo.get_tempdir() 326 | 327 | annotated_labels = ( 328 | f"{path} (new parent): {labels[0]}", 329 | f"{path} (old parent): {labels[1]}", 330 | f"{path} (current): {labels[2]}", 331 | ) 332 | (is_clean_merge, merged) = merge.merge_files( 333 | repo, 334 | annotated_labels, 335 | current.body, 336 | base.body if base else b"", 337 | other.body, 338 | tmpdir, 339 | ) 340 | 341 | if is_clean_merge: 342 | # No conflicts. 343 | return Blob(repo, merged) 344 | 345 | try: 346 | path = path.relative_to("/") 347 | except ValueError: 348 | pass 349 | 350 | # At this point, we know that there are merge conflicts to resolve. 351 | # Prompt to try and trigger manual resolution. 352 | print(f"Conflict applying '{labels[2]}'") 353 | print(f" Path: '{path}'") 354 | 355 | preimage = merged 356 | (normalized_preimage, conflict_id, merged_blob) = merge.replay_recorded_resolution( 357 | repo, tmpdir, preimage 358 | ) 359 | if merged_blob is not None: 360 | return merged_blob 361 | 362 | ON_CONFLICT(path) 363 | 364 | if input(" Edit conflicted file? (Y/n) ").lower() == "n": 365 | raise MergeConflict("user aborted") 366 | 367 | # Open the editor on the conflicted file. We ensure the relative path 368 | # matches the path of the original file for a better editor experience. 369 | conflicts = tmpdir / "conflict" / path 370 | conflicts.parent.mkdir(parents=True, exist_ok=True) 371 | conflicts.write_bytes(preimage) 372 | merged = utils.edit_file(repo, conflicts) 373 | 374 | # Print warnings if the merge looks like it may have failed. 375 | if merged == preimage: 376 | print("(note) conflicted file is unchanged") 377 | 378 | if b"<<<<<<<" in merged or b"=======" in merged or b">>>>>>>" in merged: 379 | print("(note) conflict markers found in the merged file") 380 | 381 | # Was the merge successful? 382 | if input(" Merge successful? (y/N) ").lower() != "y": 383 | raise MergeConflict("user aborted") 384 | 385 | merge.record_resolution(repo, conflict_id, normalized_preimage, merged) 386 | 387 | return Blob(current.repo, merged) 388 | 389 | gitrevise.merge.merge_blobs = override_merge_blobs 390 | 391 | def dwim(repo: Repository) -> Tuple[str, str]: 392 | rebase_dir = repo.gitdir / "rebase-merge" 393 | 394 | if os.path.exists(rebase_dir): 395 | branch = os.path.basename((rebase_dir / "head-name").read_text().strip()) 396 | base_commit = os.path.basename((rebase_dir / "onto").read_text().strip()) 397 | else: 398 | branch = repo.git("symbolic-ref", "--short", "HEAD").decode() 399 | base_commit = "@{upstream}" 400 | 401 | return branch, base_commit 402 | 403 | def parser() -> argparse.ArgumentParser: 404 | p = argparse.ArgumentParser( 405 | prog="git branchstack", 406 | description=USAGE, 407 | formatter_class=argparse.RawDescriptionHelpFormatter, 408 | ) 409 | 410 | p.add_argument( 411 | "", 412 | nargs="*", 413 | help="only create the given branches", 414 | ) 415 | 416 | p.add_argument( 417 | "--force", 418 | "-f", 419 | action="store_true", 420 | help="overwrite branches even if they were modified since the last run", 421 | ) 422 | 423 | p.add_argument( 424 | "--keep-tags", 425 | "-k", 426 | metavar="dependencies|all", 427 | nargs="?", 428 | const="dependencies", 429 | help="keep topic tag on created commits", 430 | ) 431 | 432 | p.add_argument( 433 | "--range", 434 | "-r", 435 | metavar="..", 436 | help="use commits from the given range instead of @{upstream}..", 437 | ) 438 | 439 | return p 440 | 441 | def parse_range(repo: Repository, range: str) -> Tuple[str, str]: 442 | if ".." not in range: 443 | raise InvalidRangeError(range) 444 | upper, lower = repo.git("rev-parse", range).decode().splitlines() 445 | assert lower.startswith("^") 446 | return lower[len("^") :], upper 447 | 448 | def main(argv: Optional[List[str]] = None): 449 | args = parser().parse_args(argv) 450 | try: 451 | with Repository() as repo: 452 | if args.range is None: 453 | branch, base_commit = dwim(repo) 454 | tip = "HEAD" 455 | else: 456 | branch = None 457 | base_commit, tip = parse_range(repo, args.range) 458 | if args.keep_tags is not None: 459 | if args.keep_tags not in ("dependencies", "all"): 460 | print( 461 | "argument to --keep-tags must be one of 'dependencies' (the default) or 'all'" 462 | ) 463 | sys.exit(1) 464 | base_commit = repo.git("merge-base", "--", base_commit, "HEAD").decode() 465 | create_branches( 466 | repo, 467 | branch, 468 | base_commit, 469 | tip, 470 | getattr(args, ""), 471 | force=args.force, 472 | keep_tags=args.keep_tags, 473 | ) 474 | except BranchWasModifiedError as err: 475 | print( 476 | f"error: generated branch {err} has been modified. Use --force to overwrite." 477 | ) 478 | sys.exit(1) 479 | except CalledProcessError as err: 480 | print(f"subprocess exited with non-zero status: {err.returncode}") 481 | sys.exit(1) 482 | except EditorError as err: 483 | print(f"editor error: {err}") 484 | sys.exit(1) 485 | except InvalidRangeError as err: 486 | print(f'invalid commit range: {err} should be a valid "a..b" range') 487 | sys.exit(1) 488 | except MergeConflict as err: 489 | print(f"merge conflict: {err}") 490 | sys.exit(1) 491 | except TopicNotFoundError as err: 492 | topic, base_commit, tip = err.args 493 | print(f"error: topic '{topic}' not found {base_commit}..{tip}") 494 | sys.exit(1) 495 | except ValueError as err: 496 | print(f"invalid value: {err}") 497 | sys.exit(1) 498 | 499 | if __name__ == "__main__": 500 | main() 501 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from setuptools import setup 3 | 4 | import gitbranchstack 5 | 6 | HERE = Path(__file__).resolve().parent 7 | 8 | setup( 9 | name="git-branchstack", 10 | version=gitbranchstack.__version__, 11 | packages=["gitbranchstack"], 12 | python_requires=">=3.6", 13 | entry_points={ 14 | "console_scripts": [ 15 | "git-branchstack = gitbranchstack.main:main", 16 | ], 17 | }, 18 | scripts=["git-branchstack-pick"], 19 | author="Johannes Altmanninger", 20 | author_email="aclopte@gmail.com", 21 | description="Efficiently manage Git branches without leaving your local branch", 22 | long_description=(HERE / "README.md").read_text(), 23 | long_description_content_type="text/markdown", 24 | license="MIT", 25 | keywords="git branch-workflow pull-request patch-stack", 26 | url="https://github.com/krobelus/git-branchstack/", 27 | classifiers=[ 28 | "Development Status :: 4 - Beta", 29 | "Intended Audience :: Developers", 30 | "Environment :: Console", 31 | "Topic :: Software Development :: Version Control", 32 | "Topic :: Software Development :: Version Control :: Git", 33 | "License :: OSI Approved :: MIT License", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.6", 36 | "Programming Language :: Python :: 3.7", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | ], 40 | project_urls={ 41 | "Bug Tracker": "https://github.com/krobelus/git-branchstack/issues/", 42 | "Source Code": "https://github.com/krobelus/git-branchstack/", 43 | "Documentation": "https://git.sr.ht/~krobelus/git-branchstack/", 44 | }, 45 | install_requires=[ 46 | "git-revise==0.7.0", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pytest 2 | 3 | from subprocess import Popen 4 | from gitrevise.odb import Repository 5 | from pathlib import Path 6 | import pytest 7 | import textwrap 8 | import gitbranchstack.main as gitbranchstack 9 | 10 | def test_create_branches(repo) -> None: 11 | a = repo.workdir / "a" 12 | repo.git("commit", "--allow-empty", "-m", "[a] a1") 13 | repo.git("commit", "--allow-empty", "-m", "[b] b1") 14 | repo.git("commit", "--allow-empty", "-m", "WIP commit") 15 | repo.git("commit", "--allow-empty", "-m", "[a] a2") 16 | repo.git("commit", "--allow-empty", "-m", "[a] a3") 17 | repo.git("commit", "--allow-empty", "-m", "another WIP commit") 18 | 19 | expected = """\ 20 | * (HEAD -> 🐬) another WIP commit 21 | * [a] a3 22 | * [a] a2 23 | * WIP commit 24 | * [b] b1 25 | * [a] a1 26 | * 本""" 27 | 28 | assert expected == graph(repo) 29 | 30 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 31 | 32 | expected = """\ 33 | * (HEAD -> 🐬) another WIP commit 34 | * [a] a3 35 | * [a] a2 36 | * WIP commit 37 | * [b] b1 38 | * [a] a1 39 | | * (a) a3 40 | | * a2 41 | | * a1 42 | |/ 43 | | * (b) b1 44 | |/ 45 | * 本""" 46 | 47 | assert graph(repo, "a", "b") == expected 48 | 49 | # A repeated invocation does not change anything. 50 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 51 | assert graph(repo, "a", "b") == expected 52 | 53 | # Modifying a generated branch will make us fail. 54 | repo.git("update-ref", "refs/heads/a", "HEAD") 55 | assert graph(repo, "a", "b") != expected 56 | try: 57 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 58 | assert ( 59 | False 60 | ), "Expect error about refusing to create branch when it was modified since the last run" 61 | except gitbranchstack.BranchWasModifiedError: 62 | pass 63 | 64 | # Unless we are asked to overwrite them. 65 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT, force=True) 66 | assert graph(repo, "a", "b") == expected 67 | 68 | def test_create_branches_multiline_subject(repo) -> None: 69 | repo.git("commit", "--allow-empty", "-m", "[a] multi\nline\nsubject") 70 | repo.git("commit", "--allow-empty", "-m", "[a] more\nlines\n\nmessage\nbody") 71 | 72 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 73 | assert repo.git("log", "--reverse", "--format=%B", "-2", f"a").decode() == ( 74 | "multi\nline\nsubject" + "\n" + "more\nlines\n\nmessage\nbody" + "\n" 75 | ) 76 | 77 | def test_create_branches_ambiguous_ref(repo) -> None: 78 | repo.git("update-ref", "clash", "HEAD") 79 | repo.git("commit", "--allow-empty", "-m", "[clash] commit on branch") 80 | 81 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 82 | 83 | expected = """\ 84 | * (HEAD -> 🐬) [clash] commit on branch 85 | | * (clash) commit on branch 86 | |/ 87 | * 本""" 88 | 89 | assert graph(repo, "refs/heads/clash") == expected 90 | 91 | def test_create_branches_stale_cache(repo) -> None: 92 | repo.git("commit", "--allow-empty", "-m", "[lost-branch] subject") 93 | 94 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 95 | 96 | expected = """\ 97 | * (HEAD -> 🐬) [lost-branch] subject 98 | | * (lost-branch) subject 99 | |/ 100 | * 本""" 101 | 102 | assert graph(repo, "lost-branch") == expected 103 | 104 | (repo.gitdir / "refs/heads/lost-branch").unlink() 105 | 106 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT) 107 | assert graph(repo, "lost-branch") == expected 108 | 109 | def test_create_branches_carry_over_cache(repo) -> None: 110 | repo.git("commit", "--allow-empty", "-m", "[a] subject a") 111 | repo.git("commit", "--allow-empty", "-m", "[b] subject b") 112 | 113 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT, branches=("b",)) 114 | gitbranchstack.create_branches(repo, "🐬", INITIAL_COMMIT, branches=("a",)) 115 | 116 | assert tuple( 117 | line.split()[0] 118 | for line in (repo.gitdir / "branchstack-cache").read_bytes().splitlines() 119 | ) == ( 120 | b"b", 121 | b"a", 122 | ) 123 | 124 | def test_create_branches_invalid_topic(repo) -> None: 125 | try: 126 | gitbranchstack.create_branches( 127 | repo, 128 | "🐬", 129 | INITIAL_COMMIT, 130 | branches=("invalid-topic",), 131 | ) 132 | assert False, "Expect error about missing topic" 133 | except gitbranchstack.TopicNotFoundError as e: 134 | assert e.args == ("invalid-topic", INITIAL_COMMIT, "HEAD") 135 | 136 | def test_create_branches_custom_range(repo) -> None: 137 | repo.git("commit", "--allow-empty", "-m", "[a] subject a") 138 | repo.git("commit", "--allow-empty", "-m", "[b] subject b") 139 | 140 | gitbranchstack.create_branches(repo, "🐬", "HEAD~2", "HEAD~") 141 | assert repo.git("branch", "--list", "a") 142 | assert not repo.git("branch", "--list", "b") 143 | 144 | def test_create_branches_keep_tags_in_dependencies(repo) -> None: 145 | repo.git("commit", "--allow-empty", "-m", "[b] subject b") 146 | repo.git("commit", "--allow-empty", "-m", "[a:b] subject a") 147 | 148 | gitbranchstack.create_branches(repo, None, INITIAL_COMMIT, "HEAD", keep_tags=None) 149 | assert ( 150 | repo.git("log", "--format=%s", f"{INITIAL_COMMIT}..a").decode() 151 | == "subject a\n" + "subject b" 152 | ) 153 | 154 | gitbranchstack.create_branches( 155 | repo, None, INITIAL_COMMIT, "HEAD", keep_tags="dependencies" 156 | ) 157 | assert ( 158 | repo.git("log", "--format=%s", f"{INITIAL_COMMIT}..a").decode() 159 | == "subject a\n" + "[b] subject b" 160 | ) 161 | 162 | def test_create_branches_keep_tags_in_prefixed_parents(repo) -> None: 163 | repo.git("commit", "--allow-empty", "-m", "[b] subject b") 164 | repo.git("commit", "--allow-empty", "-m", "[a:+b] subject a") 165 | 166 | gitbranchstack.create_branches(repo, None, INITIAL_COMMIT, "HEAD", keep_tags=None) 167 | assert ( 168 | repo.git("log", "--format=%s", f"{INITIAL_COMMIT}..a").decode() 169 | == "subject a\n" + "[b] subject b" 170 | ) 171 | 172 | def test_dwim(repo) -> None: 173 | origin = "origin.git" 174 | assert Popen(("git", "init", "--bare", origin)).wait() == 0 175 | repo.git("remote", "add", "origin", origin) 176 | 177 | repo.git("push", "origin", "🐬:🐳") 178 | repo.git("config", "branch.🐬.remote", "origin") 179 | repo.git("config", "branch.🐬.merge", "refs/heads/🐳") 180 | 181 | branch, base_commit = gitbranchstack.dwim(repo) 182 | assert branch == "🐬" 183 | assert base_commit == "@{upstream}" 184 | root_id = repo.git("rev-parse", INITIAL_COMMIT).decode() 185 | assert repo.git("rev-parse", "@{upstream}").decode() == root_id 186 | def commit(contents): 187 | repo.git("add", write("a", contents)) 188 | repo.git("commit", "-m", contents) 189 | commit("onto") 190 | commit("2") 191 | test_branch = "test-branch" 192 | repo.git("checkout", "-b", test_branch, "HEAD~") 193 | commit("3") 194 | 195 | assert Popen(("git", "rebase", "🐬")).wait() != 0 196 | branch, base_commit = gitbranchstack.dwim(repo) 197 | assert branch == "test-branch" 198 | assert base_commit == repo.git("rev-parse", "🐬").decode() 199 | 200 | def test_parse_log_custom_topic_affixes(repo) -> None: 201 | prefix = "" 202 | suffix = ":" 203 | repo.git("config", "branchstack.subjectPrefixPrefix", prefix.encode()) 204 | repo.git("config", "branchstack.subjectPrefixSuffix", suffix.encode()) 205 | 206 | repo.git("commit", "--allow-empty", "-m", "a: a1") 207 | repo.git("commit", "--allow-empty", "-m", "b: b1") 208 | repo.git("commit", "--allow-empty", "-m", "b: b2") 209 | repo.git("commit", "--allow-empty", "-m", "a: a2") 210 | repo.git("commit", "--allow-empty", "-m", "c:a: c1") 211 | 212 | commit_entries, dependency_graph = gitbranchstack.parse_log( 213 | repo, prefix, suffix, f"{INITIAL_COMMIT}..HEAD", "--reverse" 214 | ) 215 | assert tuple((topic, message) for commit_id, topic, message in commit_entries) == ( 216 | ("a", "a1"), 217 | ("b", "b1"), 218 | ("b", "b2"), 219 | ("a", "a2"), 220 | ("c", "c1"), 221 | ) 222 | assert dependency_graph == { 223 | "a": {}, 224 | "b": {}, 225 | "c": {"a": False}, 226 | } 227 | 228 | def test_parse_log_forward_dependency(repo) -> None: 229 | repo.git("commit", "--allow-empty", "-m", "[a:b] a") 230 | repo.git("commit", "--allow-empty", "-m", "[b] b") 231 | commit_entries, dependency_graph = gitbranchstack.parse_log( 232 | repo, "[", "]", f"{INITIAL_COMMIT}..HEAD", "--reverse" 233 | ) 234 | assert tuple((topic, message) for commit_id, topic, message in commit_entries) == ( 235 | ("a", "a"), 236 | ("b", "b"), 237 | ) 238 | assert dependency_graph == { 239 | "a": {"b": False}, 240 | "b": {}, 241 | } 242 | 243 | def test_parse_log_include_others(repo) -> None: 244 | repo.git("commit", "--allow-empty", "-m", "a b c") 245 | repo.git("commit", "--allow-empty", "-m", "[t] d e f") 246 | commit_entries, dependency_graph = gitbranchstack.parse_log( 247 | repo, 248 | "[", 249 | "]", 250 | f"{INITIAL_COMMIT}..HEAD", 251 | ) 252 | assert tuple((topic, message) for commit_id, topic, message in commit_entries) == ( 253 | ("t", "d e f"), 254 | (None, "a b c"), 255 | ) 256 | 257 | def test_transitive_dependencies() -> None: 258 | dep_graph = { 259 | "a": {"c": False}, 260 | "b": {"a": False}, 261 | "c": {"b": False}, 262 | } 263 | assert gitbranchstack.transitive_dependencies(dep_graph, ("a", False)) == { 264 | "a": False, 265 | "b": False, 266 | "c": False, 267 | } 268 | 269 | # Taken from git-revise 270 | @pytest.fixture(autouse=True) 271 | def hermetic_seal(tmp_path_factory, monkeypatch): 272 | # Lock down user git configuration 273 | home = tmp_path_factory.mktemp("home") 274 | xdg_config_home = home / ".config" 275 | monkeypatch.setenv("HOME", str(home)) 276 | monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_home)) 277 | monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "true") 278 | 279 | # Lock down commit/authoring time 280 | monkeypatch.setenv("GIT_AUTHOR_DATE", "1500000000 -0500") 281 | monkeypatch.setenv("GIT_COMMITTER_DATE", "1500000000 -0500") 282 | 283 | # Install known configuration 284 | gitconfig = home / ".gitconfig" 285 | gitconfig.write_bytes( 286 | textwrap.dedent( 287 | """\ 288 | [core] 289 | eol = lf 290 | autocrlf = false 291 | [init] 292 | defaultBranch = "🐬" 293 | [user] 294 | email = test@example.com 295 | name = Test User 296 | """ 297 | ).encode() 298 | ) 299 | monkeypatch.setenv("GIT_EDITOR", "false") 300 | 301 | # Switch into a test workdir, and init our repo 302 | workdir = tmp_path_factory.mktemp("workdir") 303 | monkeypatch.chdir(workdir) 304 | assert Popen(("git", "init", "-q")).wait() == 0 305 | assert Popen(("git", "commit", "--allow-empty", "-m", "本")).wait() == 0 306 | 307 | INITIAL_COMMIT = ":/本" 308 | 309 | @pytest.fixture 310 | def repo(hermetic_seal): 311 | with Repository() as repo: 312 | yield repo 313 | 314 | def graph(repo, *args) -> str: 315 | output = repo.git( 316 | "log", 317 | "--graph", 318 | "--oneline", 319 | "--format=%d %s", 320 | "🐬", 321 | *args, 322 | "--", 323 | ).decode() 324 | return "\n".join(line.rstrip() for line in output.splitlines()) 325 | 326 | def write(filename, contents) -> str: 327 | with open(filename, "w") as f: 328 | f.write(contents) 329 | return filename 330 | --------------------------------------------------------------------------------