├── .gitignore ├── LICENSE ├── README.md ├── git-oops ├── test_git_undo.py └── todos.md /.gitignore: -------------------------------------------------------------------------------- 1 | .hypothesis 2 | vendor 3 | vendor_linux 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Julia Evans 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 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, 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, 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 | **EXPERIMENTAL SOFTWARE, MAY DO DESTRUCTIVE THINGS TO YOUR GIT REPOSITORY. PROBABLY DO NOT USE THIS ON A REPO YOU CARE ABOUT.** 2 | 3 | # git oops 4 | 5 | Have you ever made a mistake with git and wished you could just type `git undo` 6 | instead of having to remember a weird incantation? That's the idea behind `git oops`. 7 | 8 | ``` 9 | $ git rebase -i main 10 | # do something bad here 11 | $ git oops undo 12 | # fixed! 13 | ``` 14 | 15 | The goal is to experiment and see if it's possible to build a standalone `undo` 16 | feature for git, in the style of the undo features in [GitUp](https://gitup.co/), 17 | [jj](https://github.com/martinvonz/jj), and [git-branchless](https://github.com/arxanas/git-branchless). 18 | 19 | You can think of it as "version control for your version control" -- it takes 20 | snapshots of your repository and makes those into git commits that you can 21 | restore later to go back to a previous state. 22 | 23 | This is really just a prototype -- I think the idea of a standalone `undo` 24 | feature for git is cool and I'm mostly putting this out there in case it can 25 | serve as inspiration for a better tool that actually works reliably. There's a 26 | long list of problems at the end of this README and it's not remotely ready for 27 | production use. 28 | 29 | ## installation 30 | 31 | * Put the `git-oops` script into your PATH somewhere 32 | * Install `pygit2` globally on your system 33 | * Run `git oops init` in a repository to install the hooks (danger! will overwrite your existing hooks!) 34 | * If you'd like, alias `git undo` to `git oops undo` 35 | 36 | Now `git-oops` will automatically take a snapshot any time you do anything in 37 | your Git repo. 38 | 39 | ## basic usage 40 | 41 | * `git oops undo` will undo the last operation. 42 | * `git oops history` shows you the history of all snapshots taken using a curses-based interface and lets you interactively pick one to restore. 43 | 44 | ## advanced usage 45 | 46 | * `git oops record` manually records a snapshot. 47 | * `git oops restore SNAPSHOT_ID` restores a specific snapshot 48 | 49 | ## how it works 50 | 51 | when `git oops record` takes a snapshot, here's what it does: 52 | 53 | 1. **save your staging area and workdir**: It creates a commit for your current staging area and working directory (very similarly to how `git stash` does). 54 | 2. **get HEAD** 55 | 3. **get all your branches and tags** 56 | 4. **check for uniqueness**: If the snapshot is exactly the same as the previous snapshot, it'll exit 57 | 5. **record everything in a commit**. Here's an example commit (from this repository). The metadata is stored in the commit message. 58 | ``` 59 | FormatVersion: 1 60 | HEAD: refs/heads/main 61 | Index: 20568a3a49feda34ad6aaa3aff7d7a578a8dee0d 62 | Workdir: 4d1a195dc04ab74cfe1cd94da826ce5b0069d264 63 | Refs: 64 | refs/heads/libgit2: c02fc253375108ec797b6af3ca957e8ea0cc36b9 65 | refs/heads/main: 1b4cdfab2900b3b99473560e76e3f91c560364a0 66 | refs/heads/test: 9ac4a5d8e10b04cdddab698e8a9053e7e645543c 67 | refs/heads/test2: 4247707e426f4b890ecd7314376c4d706a2d799d 68 | ``` 69 | 6. **update the git-undo reference**: It updates `refs/git-undo` to point at the commit it created in step 5. 70 | 7. **update the reflog**: it updates the reflog for `refs/git-undo` to include the new commit 71 | 72 | More details about other commands: 73 | 74 | * `git oops history` retrieves the history from the reflog for `refs/git-undo` 75 | * `git oops restore COMMIT_ID`: 76 | * retrieves COMMIT_ID 77 | * runs `git -c core.hooksPath=/dev/null restore --source WORKDIR_COMMIT` 78 | * runs `git -c core.hooksPath=/dev/null restore --staged --source INDEX_COMMIT` 79 | * updates all the branches and tags from the snapshot. It will not delete any branches or tags, to avoid deleting their reflog. 80 | * `git oops history`: 81 | * runs the equivalent of `git reflog git-oops` to get a list of histories 82 | * gives you an interactive UI to choose one to restore 83 | * `git oops undo`: 84 | * runs the equivalent of `git reflog git-oops` to get a list of histories 85 | * finds the first one where any of your references changed and restores that one 86 | * `git oops init` installs the following hooks: post-applypatch, post-checkout, pre-commit, post-commit, post-merge, post-rewrite, post-index-change, reference-transaction 87 | * when the hooks run, it runs `git oops record` 88 | 89 | ### idea: make it easier to share broken repo states 90 | 91 | You could imagine using this to share repository snapshots with your coworkers, like 92 | 93 | you run: 94 | 95 | ``` 96 | $ git oops record 97 | 23823fe 98 | $ git branch my-broken-snapshot 23823fe 99 | $ git push my-broken-snapshot 100 | ``` 101 | 102 | they run: 103 | 104 | ``` 105 | # make a fresh clone 106 | $ git clone your-repo 107 | $ git fetch origin my-broken-snapshot 108 | $ git oops restore 23823fe 109 | ``` 110 | 111 | now they can see what weird state you ended up in and help you fix it! 112 | 113 | I think this doesn't quite work as is though. 114 | 115 | ### problems 116 | 117 | Current problems include: 118 | 119 | * `git oops init` overwrites your git hooks and doesn't give you any way to uninstall them. You need to uninstall it manually 120 | * It doesn't really support multiple undos 121 | * there's no preview for what it's going to do so it's kind of scary 122 | * makes all of your commits and rebases slower, in some cases MUCH slower. Maybe Python is not a good choice of language? Not sure. 123 | * it's mostly untested so I don't trust it 124 | * probably a million other things 125 | 126 | ## acknowledgements 127 | 128 | People who helped: Kamal Marhubi, Dave Vasilevsky, Marie Flanagan, David Turner 129 | 130 | Inspired by [GitUp](https://gitup.co/), [jj](https://github.com/martinvonz/jj), and [git-branchless](https://github.com/arxanas/git-branchless), if you want an 131 | undo feature to actually use one of those tools is a better bet. 132 | -------------------------------------------------------------------------------- /git-oops: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import datetime 4 | import subprocess 5 | import argparse 6 | import os 7 | import time 8 | import curses 9 | 10 | import pygit2 11 | 12 | UNDO_REF = "refs/git-oops" 13 | 14 | 15 | def check_call(cmd, **kwargs): 16 | is_shell = type(cmd) is str 17 | if is_shell: 18 | print(f"Running command: '{cmd}'") 19 | else: 20 | print(f"running command: '{' '.join(cmd)}'") 21 | start = time.time() 22 | subprocess.check_call(cmd, shell=is_shell, **kwargs) 23 | elapsed = time.time() - start 24 | # print(f"Command took {elapsed:.3f} seconds: {cmd}") 25 | 26 | 27 | def check_output(cmd, **kwargs): 28 | is_shell = type(cmd) is str 29 | # if is_shell: 30 | # print(f"Running command: '{cmd}'") 31 | # else: 32 | # print(f"running command: '{' '.join(cmd)}'") 33 | start = time.time() 34 | result = subprocess.check_output(cmd, shell=is_shell, **kwargs).decode("utf-8") 35 | elapsed = time.time() - start 36 | # print(f"Command took {elapsed:.3f} seconds: {cmd}") 37 | return result 38 | 39 | 40 | def snapshot_head(repo): 41 | return repo.references["HEAD"].target 42 | 43 | 44 | def snapshot_refs(repo): 45 | refs = [(ref, repo.references[ref].target) for ref in repo.references] 46 | refs = [ 47 | ref 48 | for ref in refs 49 | if (ref[0].startswith("refs/tags/") or ref[0].startswith("refs/heads")) 50 | ] 51 | return refs 52 | 53 | 54 | def add_undo_entry(repo, tree, message, index_commit, workdir_commit): 55 | parents = [index_commit, workdir_commit] 56 | signature = pygit2.Signature("git-undo", "undo@example.com") 57 | undo_ref = None 58 | old_target = None 59 | try: 60 | undo_ref = repo.references[UNDO_REF] 61 | old_target = undo_ref.target 62 | except KeyError: 63 | pass 64 | commit_id = repo.create_commit(None, signature, signature, message, tree, parents) 65 | # `repo.create_reference` says: 66 | # > The message for the reflog will be ignored if the reference does not 67 | # > belong in the standard set (HEAD, branches and remote-tracking branches) 68 | # > and it does not have a reflog. 69 | # so we need to create the reflog explicitly 70 | 71 | reflog_file = os.path.join(repo.path, "logs", UNDO_REF) 72 | # create dirs if they're missing 73 | os.makedirs(os.path.join(repo.path, "logs/refs"), exist_ok=True) 74 | 75 | # create empty file if it doesn't exist 76 | if not os.path.exists(reflog_file): 77 | open(reflog_file, "a").close() 78 | 79 | reflog_message = "snapshot" 80 | if undo_ref: 81 | undo_ref.set_target(commit_id, reflog_message) 82 | else: 83 | repo.create_reference(UNDO_REF, str(commit_id), message=reflog_message) 84 | 85 | 86 | def make_commit(repo, tree): 87 | date = datetime.datetime(1970, 1, 1, 0, 0, 0) 88 | signature = pygit2.Signature("git-undo", "undo@example.com", int(date.timestamp())) 89 | commit = repo.create_commit( 90 | None, 91 | signature, 92 | signature, 93 | "snapshot", 94 | tree, 95 | [], 96 | ) 97 | return str(commit) 98 | 99 | 100 | def snapshot_index(repo): 101 | our_index = os.path.join(repo.path, "undo-index") 102 | # it's important that we use `index.lock` instead of `index` here because 103 | # we're often in the middle of an index transaction when snapshotting. 104 | # Otherwise we'll give an incorrect impression of the current state of the index 105 | # 106 | # This is really kind of a weird thing to do (what if the transaction fails 107 | # and git removes the `index.lock` without moving it to `index`? But for 108 | # now it seems better than the alternative, which is that after the commit 109 | # is made, in the `reference-transaction` hook it still appears as if we're 110 | # using the old index. 111 | if os.path.exists(os.path.join(repo.path, "index.lock")): 112 | check_output(["cp", os.path.join(repo.path, "index.lock"), our_index]) 113 | else: 114 | check_output(["cp", os.path.join(repo.path, "index"), our_index]) 115 | index = pygit2.Index(our_index) 116 | tree = index.write_tree(repo) 117 | return str(tree), make_commit(repo, str(tree)) 118 | 119 | 120 | def snapshot_workdir(repo, index_commit): 121 | our_index = os.path.join(repo.path, "undo-index") 122 | env = { 123 | "GIT_INDEX_FILE": our_index, 124 | } 125 | check_output(["git", "-c", "core.hooksPath=/dev/null", "add", "-u"], env=env) 126 | index = pygit2.Index(our_index) 127 | tree = index.write_tree(repo) 128 | return tree, make_commit(repo, tree) 129 | 130 | 131 | class Snapshot: 132 | def __init__( 133 | self, 134 | id, 135 | refs, 136 | head, 137 | index_tree, 138 | workdir_tree, 139 | index_commit, 140 | workdir_commit, 141 | ): 142 | self.id = id 143 | self.refs = refs 144 | self.head = head 145 | self.index_tree = index_tree 146 | self.workdir_tree = workdir_tree 147 | self.index_commit = index_commit 148 | self.workdir_commit = workdir_commit 149 | 150 | def __eq__(self, other): 151 | if isinstance(other, Snapshot): 152 | return ( 153 | self.refs == other.refs 154 | and self.head == other.head 155 | and self.index_commit == other.index_commit 156 | and self.workdir_commit == other.workdir_commit 157 | ) 158 | 159 | @classmethod 160 | def record(cls, repo): 161 | index_tree, index_commit = snapshot_index(repo) 162 | workdir_tree, workdir_commit = snapshot_workdir(repo, index_commit) 163 | return cls( 164 | id=None, 165 | refs=snapshot_refs(repo), 166 | head=snapshot_head(repo), 167 | index_commit=index_commit, 168 | workdir_commit=workdir_commit, 169 | index_tree=index_tree, 170 | workdir_tree=workdir_tree, 171 | ) 172 | 173 | @classmethod 174 | def load_all(cls, repo): 175 | return [Snapshot.load(repo, x.oid_new) for x in repo.references[UNDO_REF].log()] 176 | 177 | def format(self): 178 | # no newlines in message 179 | return "\n".join( 180 | [ 181 | f"FormatVersion: 1", 182 | # f"Message: {self.message}", 183 | # todo: add undo 184 | f"HEAD: {self.head}", 185 | f"Index: {self.index_commit}", 186 | f"Workdir: {self.workdir_commit}", 187 | f"Refs:", 188 | *[f"{ref}: {sha1}" for ref, sha1 in self.refs], 189 | ] 190 | ) 191 | 192 | def __str__(self): 193 | return self.format() 194 | 195 | def save(self, repo): 196 | message = self.format() 197 | 198 | last_commit = read_branch(repo, UNDO_REF) 199 | if last_commit: 200 | last_message = repo[last_commit].message 201 | if last_message == message: 202 | # print("No changes to save") 203 | return 204 | 205 | return add_undo_entry( 206 | repo=repo, 207 | message=message, 208 | tree=self.workdir_tree, 209 | index_commit=self.index_commit, 210 | workdir_commit=self.workdir_commit, 211 | ) 212 | 213 | @classmethod 214 | def load(cls, repo, commit_id): 215 | message = repo[commit_id].message 216 | 217 | # parse message 218 | lines = message.splitlines() 219 | lines = [line.strip() for line in lines] 220 | 221 | # pop things off beginning 222 | format_version = lines.pop(0) 223 | assert format_version == "FormatVersion: 1" 224 | 225 | # message = lines.pop(0) 226 | # assert message.startswith("Message: ") 227 | # message = message[len("Message: ") :] 228 | 229 | head = lines.pop(0) 230 | assert head.startswith("HEAD:") 231 | if len(head.split()) == 2: 232 | head = head.split()[1].strip() 233 | else: 234 | head = None 235 | 236 | index = lines.pop(0) 237 | assert index.startswith("Index: ") 238 | index = index.split()[1].strip() 239 | 240 | workdir = lines.pop(0) 241 | assert workdir.startswith("Workdir: ") 242 | workdir = workdir.split()[1].strip() 243 | 244 | ref_header = lines.pop(0) 245 | assert ref_header == "Refs:" 246 | 247 | refs = [] 248 | 249 | while lines: 250 | ref = lines.pop(0) 251 | ref_name, sha1 = ref.split(":") 252 | refs.append((ref_name.strip(), sha1.strip())) 253 | 254 | return cls( 255 | id=commit_id, 256 | refs=refs, 257 | head=head, 258 | index_commit=index, 259 | workdir_commit=workdir, 260 | index_tree=None, 261 | workdir_tree=None, 262 | ) 263 | 264 | def restore(self, repo): 265 | check_call( 266 | [ 267 | "git", 268 | "-c", 269 | "core.hooksPath=/dev/null", 270 | "restore", 271 | "--source", 272 | self.workdir_commit, 273 | ".", 274 | ], 275 | cwd=repo.workdir, 276 | ) 277 | check_call( 278 | [ 279 | "git", 280 | "-c", 281 | "core.hooksPath=/dev/null", 282 | "restore", 283 | "--source", 284 | self.index_commit, 285 | "--staged", 286 | ".", 287 | ], 288 | cwd=repo.workdir, 289 | ) 290 | repo.references.create("HEAD", self.head, force=True) 291 | for ref, target in self.refs: 292 | repo.references.create(ref, target, force=True) 293 | 294 | 295 | def get_head(): 296 | head_command = "git symbolic-ref HEAD" 297 | process = subprocess.Popen(head_command, shell=True, stdout=subprocess.PIPE) 298 | output, _ = process.communicate() 299 | head_ref = output.decode("utf-8").strip() 300 | return head_ref 301 | 302 | 303 | def read_branch(repo, branch): 304 | try: 305 | return repo.references[branch].target 306 | except KeyError: 307 | return None 308 | 309 | 310 | def install_hooks(repo, path="git-oops"): 311 | # List of Git hooks to install 312 | hooks_to_install = [ 313 | "post-applypatch", 314 | "post-checkout", 315 | "pre-commit", 316 | "post-commit", 317 | "post-merge", 318 | "post-rewrite", 319 | "pre-auto-gc", 320 | "post-index-change", 321 | "reference-transaction", 322 | ] 323 | 324 | # Iterate through the list of hooks and install them 325 | for hook in hooks_to_install: 326 | hook_path = os.path.join(repo.workdir, ".git", "hooks", hook) 327 | with open(hook_path, "w") as hook_file: 328 | if hook == "reference-transaction": 329 | # only record when committed 330 | hook_file.write( 331 | f"""#!/bin/sh 332 | DIR=$(git rev-parse --show-toplevel) 333 | cd $DIR || exit 334 | # check if $1 = "committed" 335 | if [ "$1" = "committed" ]; then 336 | {path} record || echo "error recording snapshot in {hook}" 337 | fi 338 | """ 339 | ) 340 | else: 341 | hook_file.write( 342 | f"""#!/bin/sh 343 | DIR=$(git rev-parse --show-toplevel) 344 | cd $DIR || exit 345 | {path} record || echo "error recording snapshot in {hook}" 346 | """ 347 | ) 348 | os.chmod(hook_path, 0o755) 349 | 350 | 351 | def record_snapshot(repo): 352 | if check_rebase(repo): 353 | return 354 | snapshot = Snapshot.record(repo) 355 | return snapshot.save(repo) 356 | 357 | 358 | def restore_snapshot(repo, commit_id): 359 | snapshot = Snapshot.load(repo, commit_id) 360 | return snapshot.restore(repo) 361 | 362 | 363 | def undo(repo): 364 | now = Snapshot.record(repo) 365 | now.save(repo) 366 | for commit in repo.references[UNDO_REF].log(): 367 | then = Snapshot.load(repo, commit.oid_new) 368 | changes = calculate_diff(now, then) 369 | if changes["refs"] or changes["HEAD"]: 370 | print(f"Restoring snapshot {then.id}") 371 | restore_snapshot(repo, then.id) 372 | return 373 | 374 | 375 | def calculate_diff(now, then): 376 | # get list of changed refs 377 | changes = { 378 | "refs": {}, 379 | "HEAD": None, 380 | "workdir": None, 381 | "index": None, 382 | } 383 | 384 | for ref, new_target in now.refs: 385 | if ref[:10] != "refs/heads" and ref[:9] != "refs/tags": 386 | continue 387 | old_target = dict(then.refs).get(ref) 388 | if str(old_target) != str(new_target): 389 | changes["refs"][ref] = (old_target, new_target) 390 | 391 | if then.head != now.head: 392 | changes["HEAD"] = (then.head, now.head) 393 | if then.workdir_commit != now.workdir_commit: 394 | changes["workdir"] = (then.workdir_commit, now.workdir_commit) 395 | if then.index_commit != now.index_commit: 396 | changes["index"] = (then.index_commit, now.index_commit) 397 | return changes 398 | 399 | 400 | def count_commits_between(repo, base, target): 401 | walker = repo.walk(target, pygit2.GIT_SORT_TOPOLOGICAL) 402 | 403 | # Count the number of commits between the base and old_commit 404 | commit_count = 0 405 | for commit in walker: 406 | if commit.id == base: 407 | break 408 | commit_count += 1 409 | 410 | return commit_count 411 | 412 | 413 | def compare(repo, old_commit, new_commit): 414 | base = repo.merge_base(old_commit, new_commit) 415 | # get number of commits between base and old_commit 416 | 417 | old_count = count_commits_between(repo, base, old_commit) 418 | new_count = count_commits_between(repo, base, new_commit) 419 | 420 | if old_count > 0 and new_count > 0: 421 | return f"have diverged by {old_count} and {new_count} commits" 422 | elif old_count == 1: 423 | return f"will move forward by {old_count} commit" 424 | elif old_count > 1: 425 | return f"will move forward by {old_count} commits" 426 | elif new_count == 1: 427 | return f"will move back by {new_count} commit" 428 | elif new_count > 1: 429 | return f"will move back by {new_count} commits" 430 | else: 431 | raise Exception("should not be here") 432 | 433 | 434 | def format_status(then, now): 435 | then_head = resolve_head(then) 436 | staged_diff = check_output( 437 | [ 438 | "git", 439 | "diff", 440 | "--stat", 441 | then_head, 442 | then.index_commit, 443 | ] 444 | ) 445 | unstaged_diff = check_output( 446 | ["git", "diff", "--stat", then.workdir_commit, then.index_commit] 447 | ) 448 | result = [] 449 | if len(staged_diff.strip()) > 0: 450 | result.append("Staged changes:") 451 | result += staged_diff.rstrip("\n").split("\n") 452 | 453 | if len(unstaged_diff.strip()) > 0: 454 | result.append("Unstaged changes:") 455 | result += unstaged_diff.rstrip("\n").split("\n") 456 | return ("git status", result) 457 | 458 | 459 | def check_rebase(repo): 460 | if os.path.exists(os.path.join(repo.path, "rebase-apply")): 461 | return True 462 | if os.path.exists(os.path.join(repo.path, "rebase-merge")): 463 | return True 464 | return False 465 | 466 | 467 | def format_changes(repo, changes, now, then): 468 | boxes = [] 469 | then_head = resolve_head(then) 470 | for ref, (old_target, new_target) in changes["refs"].items(): 471 | boxes.append( 472 | (f"{ref} changed", draw_ascii_diagram(repo, old_target, new_target)) 473 | ) 474 | 475 | if changes["HEAD"]: 476 | then_target, now_target = changes["HEAD"] 477 | boxes.append( 478 | ("current branch", [f"will move from branch {now_target} to {then_target}"]) 479 | ) 480 | if changes["workdir"]: 481 | old_workdir, new_workdir = changes["workdir"] 482 | boxes.append( 483 | ( 484 | "diff from current workdir", 485 | check_output(["git", "diff", "--stat", new_workdir, old_workdir]) 486 | .rstrip("\n") 487 | .split("\n"), 488 | ) 489 | ) 490 | 491 | boxes.append(format_status(then, now)) 492 | return boxes 493 | 494 | 495 | def resolve_head(snapshot): 496 | if snapshot.head.startswith("refs/"): 497 | return dict(snapshot.refs)[snapshot.head] 498 | return snapshot.head 499 | 500 | 501 | def index_clean(): 502 | try: 503 | # Use the 'git status' command to check the status of the working directory and index 504 | git_status_command = "git status --porcelain" 505 | process = subprocess.Popen( 506 | git_status_command, shell=True, stdout=subprocess.PIPE 507 | ) 508 | output, _ = process.communicate() 509 | 510 | # If the 'git status' command returns an empty string, both the working directory and index are clean 511 | return len(output.decode("utf-8").strip()) == 0 512 | 513 | except subprocess.CalledProcessError as e: 514 | print("Error checking if the index is clean:", e) 515 | return False 516 | 517 | 518 | def parse_args(): 519 | parser = argparse.ArgumentParser(description="Git Snapshot Tool") 520 | 521 | # Create a subparser for the 'record' subcommand 522 | record_parser = parser.add_subparsers(title="subcommands", dest="subcommand") 523 | record_parser.add_parser("record", help="Record a new snapshot") 524 | 525 | # Create a subparser for the 'undo' subcommand 526 | undo_parser = record_parser.add_parser( 527 | "undo", help="Undo the last snapshot (todo: this is a lie)" 528 | ) 529 | undo_parser = record_parser.add_parser("history", help="Display snapshot history") 530 | init_parser = record_parser.add_parser("init", help="Install hooks") 531 | 532 | restore_parser = record_parser.add_parser( 533 | "restore", help="Restore a specific snapshot" 534 | ) 535 | restore_parser.add_argument("snapshot_id", type=str, help="Snapshot ID to restore") 536 | 537 | args = parser.parse_args() 538 | 539 | repository_path = pygit2.discover_repository(".") 540 | repo = pygit2.Repository(repository_path) 541 | 542 | if args.subcommand == "record": 543 | record_snapshot(repo) 544 | elif args.subcommand == "history": 545 | CursesApp(repo) 546 | elif args.subcommand == "undo": 547 | undo(repo) 548 | elif args.subcommand == "init": 549 | install_hooks(repo) 550 | elif args.subcommand == "restore": 551 | if args.snapshot_id: 552 | restore_snapshot(repo, args.snapshot_id) 553 | else: 554 | print("Snapshot ID is required for the 'restore' subcommand.") 555 | else: 556 | # print help text 557 | parser.print_help() 558 | 559 | 560 | def get_reflog_message(repo): 561 | head = repo.references.get("HEAD") 562 | reflog = next(head.log()) 563 | return reflog.message 564 | 565 | 566 | def main(): 567 | # start = time.time() 568 | parse_args() 569 | # elapsed = time.time() - start 570 | # print(f"Time taken: {elapsed:.2f}s") 571 | 572 | 573 | class CursesApp: 574 | def __init__(self, repo): 575 | self.items = Snapshot.load_all(repo) 576 | self.current_item = 0 577 | self.pad_pos = 0 # Position of the viewport in the pad (top line) 578 | self.repo = repo 579 | curses.wrapper(self.run) 580 | 581 | def run(self, stdscr): 582 | self.stdscr = stdscr 583 | self.setup_curses() 584 | self.main_loop() 585 | 586 | def setup_curses(self): 587 | curses.curs_set(0) 588 | self.stdscr.nodelay(1) 589 | curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) 590 | 591 | def main_loop(self): 592 | changed = True 593 | while True: 594 | if changed: 595 | self.refresh() 596 | changed = self.handle_input() 597 | 598 | def refresh(self): 599 | self.draw_details() 600 | 601 | def set_title(self, title): 602 | title = " " + title + " " 603 | # get screen width 604 | _, maxx = self.stdscr.getmaxyx() 605 | self.stdscr.addstr(0, (maxx - len(title)) // 2, title) 606 | 607 | def draw_box(self, y, width, title="", content=[]): 608 | if content == []: 609 | content = [""] 610 | x = 1 611 | height = len(content) + 2 # +2 for top and bottom borders of the box 612 | win = curses.newwin(height, width, y, x) 613 | win.box() 614 | # Add the title to the box 615 | win.addstr(0, 3, title) 616 | 617 | # Add content to the box 618 | for idx, line in enumerate(content): 619 | win.addstr(idx + 1, 1, line) 620 | 621 | return ( 622 | win, 623 | y + height, 624 | ) # Return the y-coordinate for the next box to ensure no overlap 625 | 626 | def draw_details(self): 627 | self.stdscr.clear() 628 | self.stdscr.box() # Re-draw box after clearing 629 | 630 | snapshot = self.items[self.current_item] 631 | self.set_title("back " + str(self.current_item) + ": " + str(snapshot.id)[:6]) 632 | now = Snapshot.record(self.repo) 633 | now.save(self.repo) 634 | changes = calculate_diff(now, snapshot) 635 | boxes = format_changes(self.repo, changes, now, snapshot) 636 | self.windows = [] 637 | y_next = 1 638 | for title, content in boxes: 639 | win, y_next = self.draw_box(y_next, width=90, title=title, content=content) 640 | self.windows.append(win) 641 | # Put "Press r to restore" at the bottom of the screen 642 | # Put "Press q to quit" at the bottom of the screen 643 | self.stdscr.addstr( 644 | curses.LINES - 1, 645 | 1, 646 | "Press r to restore, q to quit, arrow keys to navigate", 647 | curses.color_pair(1), 648 | ) 649 | # draw all the windows 650 | self.stdscr.refresh() 651 | for win in self.windows: 652 | win.refresh() 653 | 654 | def handle_input(self): 655 | key = self.stdscr.getch() 656 | max_y, _ = self.stdscr.getmaxyx() 657 | 658 | if key == -1: 659 | # No input 660 | time.sleep(0.01) # Sleep briefly to prevent 100% CPU usage 661 | return False 662 | elif key == ord("q"): 663 | exit() 664 | elif key == curses.KEY_DOWN and self.current_item < len(self.items) - 1: 665 | self.current_item += 1 666 | elif key == curses.KEY_LEFT and self.current_item < len(self.items) - 1: 667 | self.current_item += 1 668 | elif key == curses.KEY_UP and self.current_item > 0: 669 | self.current_item -= 1 670 | elif key == curses.KEY_RIGHT and self.current_item > 0: 671 | self.current_item -= 1 672 | elif key == curses.KEY_RESIZE: 673 | pass 674 | # restore on "r" 675 | elif key == ord("r"): 676 | self.items[self.current_item].restore(self.repo) 677 | # exit ncurses and print message 678 | curses.endwin() 679 | print(f"Restored snapshot {self.items[self.current_item].id}") 680 | exit() 681 | else: 682 | return False 683 | return True 684 | 685 | 686 | def get_commits_after_ancestor(repo, commit, ancestor, include=False): 687 | commits = [] 688 | while commit and commit.id != ancestor.id: 689 | commits.append(commit) 690 | if len(commit.parent_ids) > 0: 691 | commit = repo.get(commit.parent_ids[0]) 692 | else: 693 | commit = None 694 | if include: 695 | commits.append(ancestor) 696 | return commits 697 | 698 | 699 | def draw_ascii_diagram(repo, then_sha, now_sha): 700 | then = repo.get(str(then_sha)) 701 | now = repo.get(str(now_sha)) 702 | 703 | assert then and now, "Invalid SHA" 704 | 705 | # Find common ancestor 706 | ancestor_sha = repo.merge_base(then.id, now.id) 707 | ancestor = repo.get(ancestor_sha) 708 | assert ancestor, "Couldn't get ancestor SHA" 709 | if ancestor.id == then.id or ancestor.id == now.id: 710 | return draw_line_diagram(repo, then, now, ancestor) 711 | else: 712 | return draw_diverged_diagram(repo, then, now, ancestor) 713 | 714 | 715 | def symbol(commit, then, now): 716 | if commit == then: 717 | return "➤" 718 | elif commit == now: 719 | return "★" 720 | else: 721 | return " " 722 | 723 | 724 | def draw_diverged_diagram(repo, then, now, ancestor): 725 | then_commits = get_commits_after_ancestor(repo, then, ancestor) 726 | now_commits = get_commits_after_ancestor(repo, now, ancestor) 727 | 728 | max_len = max(len(then_commits), len(now_commits)) 729 | 730 | # normalize lengths to pad out the shorter list with `None` at the beginning 731 | then_commits = normalize_lengths(then_commits, max_len) 732 | now_commits = normalize_lengths(now_commits, max_len) 733 | 734 | result = [] 735 | for i in range(max_len): 736 | left = then_commits[i] 737 | right = now_commits[i] 738 | 739 | left_str = ( 740 | f"{symbol(left, then, now)}{short(left)} {truncate_message(left.message)}" 741 | if left 742 | else " " * 44 743 | ) 744 | right_str = ( 745 | f"{symbol(right, then, now)}{short(right)} {truncate_message(right.message)}" 746 | if right 747 | else "" 748 | ) 749 | 750 | result.append(f"{left_str.ljust(44)} {right_str.ljust(23)}") 751 | 752 | result.append(" ┬" + " " * 43 + "┬") 753 | result.append(" ┝" + "─" * 43 + "┘") 754 | result.append(" │") 755 | result.append(f" {short(ancestor)} {truncate_message(ancestor.message, 60)}") 756 | return result 757 | 758 | 759 | def draw_line_diagram(repo, then, now, ancestor): 760 | # draw a simple version in the case that the ancestor is same as then or now 761 | if then.id == ancestor.id: 762 | history = get_commits_after_ancestor(repo, now, then, include=True) 763 | elif now.id == ancestor.id: 764 | history = get_commits_after_ancestor(repo, then, now, include=True) 765 | else: 766 | raise Exception("Ancestor must be same as then or now") 767 | 768 | # if there are more than 6 commits, truncate the middle 769 | num_omitted = len(history) - 5 770 | if len(history) > 6: 771 | history = history[:3] + [None] + history[-2:] 772 | 773 | return [ 774 | f"{symbol(commit, then, now)}{short(commit)} {commit.message.strip()}" 775 | if commit 776 | else f" ... {num_omitted} commits omitted ..." 777 | for commit in history 778 | ] 779 | 780 | 781 | def truncate_message(message, length=34): 782 | message = message.strip() 783 | if len(message) > length: 784 | return message[: length - 3] + "..." 785 | return message 786 | 787 | 788 | def short(commit): 789 | return str(commit.id)[:6] 790 | 791 | 792 | def normalize_lengths(l, max_len): 793 | return [None] * (max_len - len(l)) + l 794 | 795 | 796 | if __name__ == "__main__": 797 | main() 798 | -------------------------------------------------------------------------------- /test_git_undo.py: -------------------------------------------------------------------------------- 1 | from git_undo import Snapshot 2 | import time 3 | import subprocess 4 | import string 5 | import os 6 | import tempfile 7 | import git_undo 8 | import pygit2 9 | 10 | 11 | def make_git_commands(): 12 | # make commits, make a branch, reset --hard at some point 13 | return [ 14 | "git commit --allow-empty -m 'a'", 15 | "git checkout -b test", 16 | "git commit --allow-empty -m 'b'", 17 | "git checkout main", 18 | "git reset --hard test", 19 | ] 20 | 21 | 22 | def setup(): 23 | repo_path = tempfile.mkdtemp() 24 | subprocess.check_call(["git", "init", repo_path]) 25 | repo = pygit2.Repository(repo_path) 26 | 27 | # install hooks 28 | path = "python3 " + os.path.dirname(os.path.realpath(__file__)) + "/git_undo.py" 29 | git_undo.install_hooks(repo, path) 30 | return repo 31 | 32 | 33 | def delete(repo): 34 | subprocess.check_call(["rm", "-rf", repo.workdir]) 35 | 36 | 37 | def test_basic_snapshot(): 38 | repo = setup() 39 | subprocess.check_call( 40 | ["git", "commit", "--allow-empty", "-am", "test"], cwd=repo.workdir 41 | ) 42 | all_snapshots = Snapshot.load_all(repo) 43 | # um not sure if I agree with this? why 2 snapshots? 44 | assert len(all_snapshots) == 2 45 | assert all_snapshots[0].head == "refs/heads/main" 46 | 47 | delete(repo) 48 | 49 | 50 | def add_file(repo, filename, contents): 51 | with open(os.path.join(repo.workdir, filename), "w") as f: 52 | f.write(contents) 53 | subprocess.check_call(["git", "add", filename], cwd=repo.workdir) 54 | 55 | 56 | def test_accurate_restore(): 57 | repo = setup() 58 | subprocess.check_call( 59 | ["git", "commit", "--allow-empty", "-am", "initial commit"], cwd=repo.workdir 60 | ) 61 | add_file(repo, "a.txt", "aaaaa") 62 | subprocess.check_call(["git", "commit", "-m", "a.txt"], cwd=repo.workdir) 63 | # wait a moment 64 | snapshot_id = Snapshot.load_all(repo)[0].id 65 | 66 | add_file(repo, "b.txt", "bbbbb") 67 | subprocess.check_call(["git", "commit", "-m", "b.txt"], cwd=repo.workdir) 68 | 69 | git_undo.restore_snapshot(repo, snapshot_id) 70 | 71 | # make sure that "b.txt" is gone 72 | assert not os.path.exists(os.path.join(repo.workdir, "b.txt")), "b.txt still exists" 73 | 74 | 75 | # todo: test that restoring most recent snapshot is a no-op 76 | 77 | # def test_successive_snapshots(): 78 | # return 79 | # git_commands = make_git_commands() 80 | # # Invariant 2: No two successive snapshots should be identical 81 | # # assert that repo_path exists 82 | # subprocess.check_call(["git", "init", repo_path]) 83 | # assert os.path.exists(repo_path) 84 | # 85 | # for command in git_commands: 86 | # subprocess.check_call(command, cwd=repo_path) 87 | # snapshots = Snapshot.load_all(conn) 88 | # if len(snapshots) >= 2: 89 | # assert snapshots[0] != snapshots[1], "successive snapshots are identical" 90 | # 91 | # snapshots = Snapshot.load_all(conn) 92 | -------------------------------------------------------------------------------- /todos.md: -------------------------------------------------------------------------------- 1 | problems: 2 | 3 | - [X] index commit in snapshot is not actually immutable, that sucks (fixed) 4 | - [X] can't do snapshots in reference-transaction or post-index-change (fix: don't run hooks) 5 | - [X] fix snapshots inside snapshots (fix: don't run a hooks, do I still need the lockfile?) 6 | - [X] messages don't give you the actual operation that's running 7 | - [X] 130ms to make a recording is a bit slow (idea: pygit2?? -- for now) 8 | - [X] lock file management feels kinda flaky 9 | - [X] fix "ref HEAD is not a symbolic ref" during rebase 10 | - [X] need to implement restore 11 | - [X] no diffing in restore 12 | - [X] bug: some snapshots are identical 13 | - [X] switch to reflog design 14 | - [X] bug: changes are both staged and unstaged at the same time when making a commit (solution: use `index.lock` instead of `index`) 15 | - [X] commit is like 4 operations, reset is 3 operations (idea: implement a wrapper?) 16 | - [X] we don't update reflog when updating HEAD / other references 17 | - [X] snapshots in the middle of a rebase are confusing (removed them) 18 | - [ ] feature: add a "preview" command to show what it would be like to restore a snapshot maybe? 19 | - [ ] feature: there's no way to uninstall the hooks 20 | - [ ] bug: `git undo` on a reset --hard HEAD^^^ actually doesn't do the right thing so that sucks 21 | 22 | usability: 23 | 24 | performance: 25 | - [ ] tests are really slow :( 26 | - [ ] it slows down commits LOT (~60ms with no hooks -> -> 450ms). Rebases are painfully slow. 27 | 28 | "portability" issues: 29 | - [ ] possibly use GIT_DIR environment variable to get git dir when in a hook for better accuracy 30 | - [ ] bug: `.git/hooks` might not be accurate to get hooks dir, use libgit2 instead 31 | - [ ] bug: it overwrites all your git hooks 32 | 33 | possible problems 34 | - [ ] the thing where index / workdir are commits is a little weird (idea: look at jj's internals) 35 | - [ ] pygit2 dependency is problematic 36 | 37 | 38 | --------------------------------------------------------------------------------