From cc88af1d35f4812d001bedc48b805a8ad4329d6b Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:37:14 -0700 Subject: [PATCH] Let checkout -b take a start-point commit The command now accepts one optional start point with -b and validates that it resolves to a commit. The scene draws the target history when needed, places the new branch and HEAD at the target, and keeps the original branch at its previous commit. Remote-tracking start points receive a remote label and an upstream explanation. The command title, CLI help, and README include the new argument. Fixes #109 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- README.md | 4 +- src/git_sim/checkout.py | 44 ++++- src/git_sim/commands.py | 11 +- tests/unit_tests/test_scenes.py | 13 ++ tests/validation/cases.py | 4 + tests/validation/golden/checkout-new-at.json | 175 ++++++++++++++++++ .../golden/checkout-new-tracking.json | 94 ++++++++++ 7 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 tests/validation/golden/checkout-new-at.json create mode 100644 tests/validation/golden/checkout-new-tracking.json diff --git a/README.md b/README.md index 4b06879..5dea951 100644 --- a/README.md +++ b/README.md @@ -376,10 +376,10 @@ Usage: `git-sim check-ignore [-v] ...` git checkout: switch branches, or create one
-Usage: `git-sim checkout [-b] ` +Usage: `git-sim checkout [-b] []` - Checks out `` into the working directory, i.e. moves `HEAD` to the specified `` -- The `-b` flag creates a new branch with the specified name `` and checks it out, assuming it doesn't already exist +- The `-b` flag creates a new branch with the specified name `` and checks it out, assuming it doesn't already exist. An optional `` (a branch, tag, or commit) starts it there instead of at `HEAD`; a remote-tracking branch such as `origin/x` becomes its upstream [![git-sim checkout fix/order-totals](https://raw.githubusercontent.com/initialcommit-com/git-sim/main/docs/img/checkout.svg)](https://initialcommit.com/tools/git-sim/viewer?demo=checkout) diff --git a/src/git_sim/checkout.py b/src/git_sim/checkout.py index f76fd23..eb2924a 100644 --- a/src/git_sim/checkout.py +++ b/src/git_sim/checkout.py @@ -66,10 +66,12 @@ def __init__(self, paths): class Checkout(GitSimBaseCommand): - def __init__(self, branch: str, b: bool): + def __init__(self, branch: str, b: bool, start_point: str = None): super().__init__() self.branch = branch self.b = b + self.start_point = start_point + self.track = None if self.b: if self.branch in self.repo.heads: @@ -79,6 +81,14 @@ def __init__(self, branch: str, b: bool): + "', it already exists" ) sys.exit(1) + if self.start_point: + try: + self.repo.commit(self.start_point) + except Exception: + print(f"git-sim error: '{self.start_point}' is not a valid commit to start the branch at") + sys.exit(1) + if self.start_point in self.get_remote_tracking_branches(): + self.track = self.start_point else: try: git.repo.fun.rev_parse(self.repo, self.branch) @@ -116,6 +126,7 @@ def __init__(self, branch: str, b: bool): self.cmd += ( f"{type(self).__name__.lower()}{' -b' if self.b else ''} {self.branch}" + f"{' ' + self.start_point if self.start_point else ''}" ) def construct(self): @@ -125,8 +136,10 @@ def construct(self): self.show_intro() head_commit = self.get_commit() + if self.b and self.start_point: + self.create_elsewhere(head_commit) # using -b flag, create new branch label and exit - if self.b: + elif self.b: self.parse_commits(head_commit) self.recenter_frame() self.scale_frame() @@ -176,3 +189,30 @@ def construct(self): self.fadeout() self.show_command_as_title() self.show_outro() + + def create_elsewhere(self, head_commit): + """Create the branch at its start point and move HEAD onto it.""" + target = self.repo.commit(self.start_point) + self.parse_commits(head_commit) + if target.hexsha not in self.drawnCommits: + # Leave room for HEAD, the new branch and any remote label below + # the lowest row already drawn by HEAD's history. + gap = 4 if self.compact else 5 + top = self.drawnCommits[head_commit.hexsha].get_center()[1] if head_commit.hexsha in self.drawnCommits else 0.0 + lowest = min((c.get_center()[1] for c in self.drawnCommits.values()), default=top) + self.parse_commits(target, shift=(top - lowest + gap) * m.DOWN) + drawn = target.hexsha in self.drawnCommits + if drawn and self.track and self.track not in self.drawnRefs: + self.draw_ref(target, self.stack_top(target.hexsha), text=self.track, color=self.theme.remote, kind="remote", phase="before") + self.recenter_frame() + self.scale_frame() + if drawn: + self.draw_ref(target, self.stack_top(target.hexsha), text=self.branch, color=self.theme.branch) + if target.hexsha != head_commit.hexsha: + self.reset_head(target.hexsha) + if not self.repo.head.is_detached: + self.reset_branch(head_commit.hexsha) + notes = [f"Creates {self.branch} at {self.start_point} ({target.hexsha[:6]}) and checks it out."] + if self.track: + notes.append(f"{self.track} is a remote-tracking branch, so it becomes {self.branch}'s upstream.") + self.add_notes(notes) diff --git a/src/git_sim/commands.py b/src/git_sim/commands.py index 309a6e6..c6c30e3 100644 --- a/src/git_sim/commands.py +++ b/src/git_sim/commands.py @@ -137,12 +137,12 @@ def branch( def checkout( branch: List[str] = typer.Argument( default=None, - help="The name of the branch to checkout, or after -- the files whose changes to discard (git checkout -- )", + help="The branch to checkout, with an optional start point (branch, tag, or commit) after the new branch name with -b, or after -- the files whose changes to discard", ), b: bool = typer.Option( False, "-b", - help="Create the specified branch if it doesn't already exist", + help="Create and check out a new branch at HEAD or the optional start point", ), ): import sys @@ -168,10 +168,13 @@ def checkout( scene = CheckoutFiles(paths=paths) else: _need(revisions, "name the branch to check out, or the files after --") - if len(revisions) > 1: + if b and after is None and len(revisions) > 2: + print("git-sim error: git checkout -b takes a new branch and at most one start point") + raise typer.Exit(1) + if len(revisions) > 1 and not (b and after is None): print("git-sim error: git checkout takes one branch or commit") raise typer.Exit(1) - scene = Checkout(branch=revisions[0], b=b) + scene = Checkout(branch=revisions[0], b=b, start_point=revisions[1] if len(revisions) == 2 else None) handle_animations(scene=scene) diff --git a/tests/unit_tests/test_scenes.py b/tests/unit_tests/test_scenes.py index 27a33f2..790a144 100644 --- a/tests/unit_tests/test_scenes.py +++ b/tests/unit_tests/test_scenes.py @@ -929,6 +929,19 @@ def test_switch_create_at_a_start_point(repo, tmp_path): Switch(branch="topic", c=True, detach=False, start_point="nope") +def test_checkout_create_at_a_start_point(repo): + from git_sim.checkout import Checkout + + head = run_git(repo, "rev-parse", "HEAD").strip() + target = run_git(repo, "rev-parse", "HEAD~3").strip() + short = run_git(repo, "rev-parse", "--short=8", "HEAD~3").strip() + scene = Checkout(branch="topic", b=True, start_point=short) + assert scene.cmd == f"git checkout -b topic {short}" + scene.construct() + assert ref_commit(scene, "topic") == ref_commit(scene, "HEAD") == target + assert ref_commit(scene, "main") == head + + def test_annotated_tag_draws_the_tag_object(repo): from git_sim.tag import Tag diff --git a/tests/validation/cases.py b/tests/validation/cases.py index c6cdff1..f5f8782 100644 --- a/tests/validation/cases.py +++ b/tests/validation/cases.py @@ -612,6 +612,10 @@ def check(m, repo): # checkout Case("checkout-branch", "classic", ["checkout", "branch2"], all_of(title("git checkout branch2"), relabelled("HEAD"))), Case("checkout-new", "classic", ["checkout", "-b", "topic"], ref_after("topic", "branch")), + Case("checkout-new-at", "classic", ["checkout", "-b", "topic", "branch2"], all_of(title("git checkout -b topic branch2"), ref_after("topic", "branch"), relabelled("HEAD"))), + Case("checkout-new-tracking", "remote-branch", ["checkout", "-b", "idea", "origin/old-idea"], all_of(title("git checkout -b idea origin/old-idea"), ref_after("idea", "branch"), relabelled("HEAD"), texts("becomes idea's upstream"))), + Case("checkout-new-bad-start", "classic", ["checkout", "-b", "topic", "nope"], error="not a valid commit"), + Case("checkout-new-two-starts", "classic", ["checkout", "-b", "topic", "main", "branch2"], error="at most one start point"), Case("checkout-tag", "release", ["checkout", "v1.0.0"], relabelled("HEAD")), Case("checkout-commit", "classic", ["checkout", "HEAD~2"], relabelled("HEAD")), Case("checkout-missing", "classic", ["checkout", "nope"], error="git-sim error"), diff --git a/tests/validation/golden/checkout-new-at.json b/tests/validation/golden/checkout-new-at.json new file mode 100644 index 0000000..bfadd8f --- /dev/null +++ b/tests/validation/golden/checkout-new-at.json @@ -0,0 +1,175 @@ +{ + "commits": { + "Dummy commit #10 on branch1": { + "kind": "commit", + "parents": [ + "Dummy commit #9 on branch1" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #10 on branch2": { + "kind": "commit", + "parents": [ + "Dummy commit #9 on branch2" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #10 on main": { + "kind": "commit", + "parents": [ + "Dummy commit #9 on main" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #6 on branch2": { + "kind": "commit", + "parents": [ + "?" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #7 on branch1": { + "kind": "commit", + "parents": [ + "?" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #7 on branch2": { + "kind": "commit", + "parents": [ + "Dummy commit #6 on branch2" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #7 on main": { + "kind": "commit", + "parents": [ + "?" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #8 on branch1": { + "kind": "commit", + "parents": [ + "Dummy commit #7 on branch1" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #8 on branch2": { + "kind": "commit", + "parents": [ + "Dummy commit #7 on branch2" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #8 on main": { + "kind": "commit", + "parents": [ + "Dummy commit #7 on main" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #9 on branch1": { + "kind": "commit", + "parents": [ + "Dummy commit #8 on branch1" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #9 on branch2": { + "kind": "commit", + "parents": [ + "Dummy commit #8 on branch2" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Dummy commit #9 on main": { + "kind": "commit", + "parents": [ + "Dummy commit #8 on main" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Merge branch1 into main": { + "kind": "merge", + "parents": [ + "Dummy commit #10 on branch1", + "Dummy commit #10 on main" + ], + "phase": "before", + "recolored": false, + "step": 0 + } + }, + "edges": { + "after": 0, + "before": 24, + "origin": 0, + "removed": 0 + }, + "files": [], + "notes": [ + "Creates topic at branch2 (573d8f) and checks it out." + ], + "refs": { + "HEAD": { + "count": 2, + "kind": "head", + "moved": true, + "phase": "before" + }, + "branch1": { + "count": 2, + "kind": "branch", + "moved": false, + "phase": "before" + }, + "branch2": { + "count": 2, + "kind": "branch", + "moved": false, + "phase": "before" + }, + "main": { + "count": 2, + "kind": "branch", + "moved": true, + "phase": "before" + }, + "topic": { + "count": 2, + "kind": "branch", + "moved": false, + "phase": "after" + } + }, + "steps": 2, + "title": "git checkout -b topic branch2" +} diff --git a/tests/validation/golden/checkout-new-tracking.json b/tests/validation/golden/checkout-new-tracking.json new file mode 100644 index 0000000..a3d5e92 --- /dev/null +++ b/tests/validation/golden/checkout-new-tracking.json @@ -0,0 +1,94 @@ +{ + "commits": { + "Handle missing config values": { + "kind": "commit", + "parents": [ + "Raise the default log level" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Pin the test dependencies": { + "kind": "commit", + "parents": [ + "Handle missing config values" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Raise the default log level": { + "kind": "commit", + "parents": [ + "?" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Retry failed database connections": { + "kind": "commit", + "parents": [ + "Update the README quickstart" + ], + "phase": "before", + "recolored": false, + "step": 0 + }, + "Update the README quickstart": { + "kind": "commit", + "parents": [ + "Pin the test dependencies" + ], + "phase": "before", + "recolored": false, + "step": 0 + } + }, + "edges": { + "after": 0, + "before": 8, + "origin": 0, + "removed": 0 + }, + "files": [], + "notes": [ + "Creates idea at origin/old-idea (c297a4) and checks it out.", + "origin/old-idea is a remote-tracking branch, so it becomes idea's upstream." + ], + "refs": { + "HEAD": { + "count": 2, + "kind": "head", + "moved": true, + "phase": "before" + }, + "idea": { + "count": 2, + "kind": "branch", + "moved": false, + "phase": "after" + }, + "main": { + "count": 2, + "kind": "branch", + "moved": true, + "phase": "before" + }, + "origin/main": { + "count": 2, + "kind": "remote", + "moved": false, + "phase": "before" + }, + "origin/old-idea": { + "count": 2, + "kind": "remote", + "moved": false, + "phase": "before" + } + }, + "steps": 2, + "title": "git checkout -b idea origin/old-idea" +}