Introduction
Have you ever felt like you were stuck in a rut with your Git workflow? Like you were limited to a single, linear timeline, unable to easily explore different versions of your code or experiment with new features without the risk of introducing bugs? That’s how I felt for years, until I finally took the plunge and learned how to use branching and cherry-picking.
What You’ll Learn
In this article, I’ll share my journey of discovering the power of Git’s non-linear capabilities. I’ll explain how I learned to “hop” between different timelines, cherry-pick the changes I want, and even use branches as a way to explore older commits and experiment with new features.
Who This is For
Whether you’re a Git newbie or a seasoned pro, I hope this article will inspire you to take your Git skills to the next level. So come along with me on this adventure, and let’s unlock the full potential of Git together!
Jumping Into Today’s Work
Fair warning to the new reader of this blog / daily tech journal. You don’t have to read or understand the big blocks of code you’re about to encounter. This is often what I do to season LLM sessions for the day.
The LLMs have got these whopping huge context windows, so I feed them full context (this whole article as it evolves). Today, I start out with it doing an analysis of a block of code I worked on recently, and so the whole block of code appears here. I know this will turn a lot of readers away, but hey look around. See any advertising? Nope! This writing is for myself and the LLMs, primarily, but you are welcome to follow along!
I’ve got to live with these decisions forever, or at least for a good long time. So let’s get them right. Here’s my Pipulate class starting point:
class Pipulate:
"""
Pipulate manages a pipeline using a JSON blob with keys like "step_01", "step_02", etc.
No 'steps' or 'current_step' keys exist. The presence of step keys determines progress.
Data Structure Example:
{
"step_01": {"name": "John"},
"step_02": {"color": "blue"},
"created": "2024-12-08T12:34:56",
"updated": "2024-12-08T12:35:45"
}
The highest step number that exists in the JSON is considered the last completed step.
The next step is always one more than the highest completed step.
"""
def __init__(self, table):
self.table = table
def _get_state(self, url: str) -> dict:
record = self.table[url]
state = json.loads(record.data)
return state
def _save_state(self, url: str, state: dict):
now = datetime.now().isoformat()
state["updated"] = now
self.table.update({
"url": url,
"data": json.dumps(state),
"updated": state["updated"]
})
def initialize_if_missing(self, url: str, initial_step_data: dict = None) -> dict:
"""Initialize state for url if it doesn't exist"""
try:
return self._get_state(url)
except NotFoundError:
now = datetime.now().isoformat()
state = {
"created": now,
"updated": now
}
if initial_step_data:
# Extract endpoint from initial data if present
endpoint = None
if "endpoint" in initial_step_data:
endpoint = initial_step_data.pop("endpoint").strip('/')
state.update(initial_step_data)
self.table.insert({
"url": url,
"endpoint": endpoint, # New column
"data": json.dumps(state),
"created": now,
"updated": now
})
return state
def get_state(self, url: str) -> dict:
"""Get current state for url"""
try:
return self._get_state(url)
except NotFoundError:
return {}
def set_step_data(self, url: str, step_name: str, data: dict):
"""Set data for a specific step"""
state = self.get_state(url)
state[step_name] = data
self._save_state(url, state)
@pipeline_operation
def get_step_data(self, url: str, step_name: str, default=None) -> dict:
"""Get data for a specific step"""
state = self.get_state(url)
return state.get(step_name, default or {})
def generate_step_placeholders(self, steps, prefix, start_from=0):
"""Generate step placeholder divs for any workflow.
Args:
steps: List of (key, step_id, label) tuples defining the workflow
prefix: URL prefix for the workflow (e.g., "/poetx")
start_from: Index of step to trigger on load (default 0)
Returns:
List of Div elements with appropriate HTMX attributes
"""
return [
Div(
id=step_id,
hx_get=f"{prefix}/{step_id}",
hx_trigger="load" if i == start_from else None
)
for i, (_, step_id, _) in enumerate(steps)
]
def clear_steps_from(self, url: str, target_step: str, steps):
"""Clear state from target step onwards.
Args:
url: Workflow identifier
target_step: Step ID to start clearing from
steps: List of workflow steps
Returns:
Updated state dict
"""
state = self.get_state(url)
step_indices = {step_id: i for i, (_, step_id, _) in enumerate(steps)}
target_idx = step_indices[target_step]
for _, step_id, _ in steps[target_idx:]:
state.pop(step_id, None)
self._save_state(url, state)
return state
def get_step_summary(self, url: str, current_step: str, steps) -> tuple[dict, list]:
"""Get state and summary up to current step.
Args:
url: Workflow identifier
current_step: Current step being processed
steps: List of workflow steps
Returns:
(state_dict, summary_lines) where:
- state_dict: {key: value} of completed steps
- summary_lines: List of formatted "Label: value" strings
"""
# Get state up to current step
state = {}
current_step_found = False
for key, step_id, label in steps:
if current_step_found:
break
if step_id == current_step:
current_step_found = True
step_data = self.get_step_data(url, step_id, {})
if key in step_data:
state[key] = step_data[key]
# Build summary lines
summary_lines = []
for key, step_id, label in steps:
if step_id == current_step:
break
if key in state:
summary_lines.append(f"- {label}: {state[key]}")
return state, summary_lines
def revert_control(
self,
step_id: str,
prefix: str,
url: str = None,
message: str = None,
final_step: str = None,
target_id: str = "tenflow-container",
label: str = None,
style: str = None
):
"""
Return a revert control with optional styling and finalization check.
Args:
step_id: e.g. "step_03"
prefix: e.g. "/tenflow"
url: Optional pipeline URL to check finalization
message: Optional message to show in card
final_step: Optional step that marks finalization
target_id: Container ID to replace
label: Custom button label
style: Custom button style
"""
# Early return if finalized
if url and final_step and self.is_finalized(url, final_step):
return None
# Default styling if not provided
default_style = (
"background-color: var(--pico-del-color);"
"display: inline-block;"
"padding: 0.25rem 0.5rem;"
"border: 3px solid #f88;"
"border-radius: 4px;"
"font-size: 0.85rem;"
"cursor: pointer;"
)
# Create basic revert form
form = Form(
Input(type="hidden", name="step", value=step_id),
Button(
label or f"\u00A0{format_step_name(step_id)}",
type="submit",
style=style or default_style
),
hx_post=f"{prefix}/jump_to_step",
hx_target=f"#{target_id}",
hx_swap="outerHTML"
)
# Return simple form if no message
if not message:
return form
# Return styled card with message if provided
return Card(
Div(message, style="flex: 1;"),
Div(form, style="flex: 0;"),
style="display: flex; align-items: center; justify-content: space-between;"
)
def wrap_with_inline_button(
self,
input_element: Input,
button_label: str = "Next",
button_class: str = "primary"
) -> Div:
"""Wrap any input element with an inline button in a flex container."""
return Div(
input_element,
Button(
button_label,
type="submit",
cls=button_class,
style=(
"display: inline-block;"
"cursor: pointer;"
"width: auto !important;" # Override PicoCSS width: 100%
"white-space: nowrap;"
)
),
style="display: flex; align-items: center; gap: 0.5rem;"
)
def is_finalized(self, url: str, final_step: str) -> bool:
"""
Return True if the pipeline's final_step data has "finalized" in it.
Args:
url: Pipeline identifier
final_step: Step that marks finalization (e.g. "step_05")
"""
step_data = self.get_step_data(url, final_step, {})
return "finalized" in step_data
async def explain(self, caller, current_step, message=None):
"""
Minimal LLM commentary, adapted from Poetflow's 'explain'.
If llm_enabled=True, we create a background task with chatq().
Args:
caller: The calling object (e.g. BridgeFlow instance) containing llm_enabled,
STEPS, and pipeline_id via db.get()
current_step: Current step ID (e.g. "step_01")
message: Optional message to use instead of generating summary
"""
if not caller.llm_enabled:
return
pipeline_id = db.get("pipeline_id", "unknown")
# Optionally gather step summary lines from pipulate
_, summary_lines = self.get_step_summary(pipeline_id, current_step, caller.STEPS)
prompt = ""
if not message:
summary = ""
if summary_lines:
summary = "So far:\n" + "\n".join(summary_lines) + "\n\n"
prompt = (
f"Briefly summarize the user's progress at '{current_step}'.\n\n"
f"{summary}"
)
else:
prompt = message
asyncio.create_task(chatq(prompt, role="system"))
First We Look For Redundancies
I’ve got a lot of work from yesterday’s session do do ADDING to Pipulate, but before we make it bigger, I think I ought to make it smaller— and more consistent abiding by common Python conventions.
And I ask Claude if there are any redundancies in this?
Claude: Yes, there are several opportunities for improvement in the Pipulate
class:
- State Management Redundancy:
def get_state(self, url: str) -> dict:
"""Get current state for url"""
try:
return self._get_state(url)
except NotFoundError:
return {}
def _get_state(self, url: str) -> dict:
record = self.table[url]
state = json.loads(record.data)
return state
We could combine these into one method since get_state
is just a wrapper with error handling.
- Timestamp Handling:
def initialize_if_missing(self, url: str, initial_step_data: dict = None) -> dict:
# ...
now = datetime.now().isoformat()
state = {
"created": now,
"updated": now
}
# ...
def _save_state(self, url: str, state: dict):
now = datetime.now().isoformat()
state["updated"] = now
# ...
We could create a _get_timestamp()
helper method.
- Card/Form Generation:
def revert_control(self, ...): # Default styling if not provided default_style = ( "background-color: var(--pico-del-color);" "display: inline-block;" "padding: 0.25rem 0.5rem;" "border: 3px solid #f88;" "border-radius: 4px;" "font-size: 0.85rem;" "cursor: pointer;" )
The default styles could be class constants.
Here’s how we could refactor it:
class Pipulate:
# Class constants
DEFAULT_BUTTON_STYLE = (
"background-color: var(--pico-del-color);"
"display: inline-block;"
"padding: 0.25rem 0.5rem;"
"border: 3px solid #f88;"
"border-radius: 4px;"
"font-size: 0.85rem;"
"cursor: pointer;"
)
def __init__(self, table):
self.table = table
def _get_timestamp(self) -> str:
"""Get current timestamp in ISO format"""
return datetime.now().isoformat()
def get_state(self, url: str) -> dict:
"""Get current state for url with error handling"""
try:
record = self.table[url]
return json.loads(record.data)
except NotFoundError:
return {}
def _save_state(self, url: str, state: dict):
"""Save state with updated timestamp"""
state["updated"] = self._get_timestamp()
self.table.update({
"url": url,
"data": json.dumps(state),
"updated": state["updated"]
})
def initialize_if_missing(self, url: str, initial_step_data: dict = None) -> dict:
"""Initialize state for url if it doesn't exist"""
try:
return self.get_state(url)
except NotFoundError:
now = self._get_timestamp()
state = {
"created": now,
"updated": now
}
if initial_step_data:
endpoint = initial_step_data.pop("endpoint", "").strip('/') if "endpoint" in initial_step_data else None
state.update(initial_step_data)
self.table.insert({
"url": url,
"endpoint": endpoint,
"data": json.dumps(state),
"created": now,
"updated": now
})
return state
This refactoring:
- Eliminates the redundant
_get_state
method - Centralizes timestamp generation
- Moves styling constants to class level
- Simplifies endpoint extraction
- Makes the code more DRY overall
Would you like me to continue with more refactoring suggestions?
Starting To Clean The Pipes
If it’s not broken, don’t fix it! Yet here I go doing it anyway, because this code is going to propegate alot, given how I deliberately don’t derive workflows from base classes. So there’s some artistry here that just needs to be done.
Me: Okay, I combined them, got rid of _get_state and updated references to it to get_state. What did the underscore mean?
def get_state(self, url: str) -> dict:
"""Get current state for url"""
try:
record = self.table[url]
state = json.loads(record.data)
return state
except NotFoundError:
return {}
Claude: The underscore prefix in Python (e.g., _get_state
) is a naming convention that indicates the method is intended for internal use only (private-by-convention). While Python doesn’t have true private methods like some other languages, the underscore prefix tells other developers “this is an implementation detail, please don’t call this directly from outside the class.”
So in the original code:
_get_state()
was meant to be the internal implementationget_state()
was the public interface that added error handling
By combining them into a single get_state()
method (without the underscore), we’re making it clear that this is a public method that other code can safely use. This is better because:
- The error handling is now part of the core functionality
- There’s only one way to get state
- The API is simpler and clearer
It’s a good change! The underscore was suggesting a separation that wasn’t really necessary in this case.
Me: Okay, so I replaced all occurrences of _save_state
with save_state
based on the same principle, since _save_state
was used all throughout the workflows.
Claude: Yes, that’s a good decision! Since save_state
is being used throughout the workflows, it’s effectively part of the public API of the Pipulate
class, so removing the underscore makes its status clearer.
The underscore prefix would make sense if the method was only used internally by other methods within the Pipulate
class. But since it’s a core operation that other classes need to use, making it explicitly public by removing the underscore better reflects its actual role in the codebase.
This kind of refactoring helps make the API more honest - the naming now matches how the method is actually being used. It’s a good example of making the code’s intent clearer through naming conventions!
Would you like to look for other methods that might benefit from the same kind of clarification?
Git Adventures Begin
Okay, I broke something. This is going to be quite an adventure. I really only use git as a sort of personal infinite undo. And even in that regard, I use it as a linear forward and backward timeline, sort of like scrubbing forward and backwards in video editing. This is done with:
git reset --hard HEAD
Where it says HEAD, you can replace it with any hash you find when you git
log
, and so you have the ideal time machine. The hash I want to go to is
867b7b9d95dab4d189a472e6006f12252e9da1b467b7b9d95dab4d189a472e6006f12252e9da1b4
where I know the error did not exist.
The Time Travel Dilemma
The problem is that I’ve done a number of “innovations” since then, some of which I want to keep and one of which introduced a bug. Now If I simply:
git reset --hard 867b7b9d95dab4d189a472e6006f12252e9da1b467b7b9d95dab4d189a472e6006f12252e9da1b4
…then I could eliminate the but instantly because undo. However, I loose all those other improvements, and if I forced a commit with:
git commit -am "Bug gone"
git push --force
Then I have committed a new timeline. I have traveled backwards in time in the repository, undid my recent actions and start anew from that point in history. And this is generally good. This is my way of working. This does not break the brain with all the same multi-thread coordination of concurrent programming, which I also don’t like very much. I am a linear non-linear thinker. While my mind might jump around like crazy and I document this and that all over the place in a tech journal like this, my coding implementation path is singluar…
Stepping Out of the Comfort Zone
Until right now. It’s time to leave my comfort zone and to explore the realm of git branches, checkouts and cherry picking. The mere concept of which terrifies me, but I’m going to do it. And I’m going to enlist the help of… hmmm… o1 Pro or Claude 3.5 Sonnet? This is the path very much more traveled, and I don’t need the o1 Pro big thinker idea-man here. In fact, that could work against me in this case. Clause has full context of my code being the default in Cursor.
So, I’ll just feed it this whole article so far today. Claude?
Branching Out And Cherry Picking With Claude
It’s time to leave my comfort zone, and I didn’t even have to articulate the situation to Claude because I’ve been articulating it to myself through my daily tech journal. This prompting method has been highly effective with o1 Pro, but here I apply it with Claude.
Claude: I understand you want to preserve recent improvements while removing a bug, and you’re ready to explore git branching instead of your usual linear time-travel approach. Here’s what I suggest:
- First, create a branch at your current state to preserve all your work:
git branch save-improvements
- Then reset to the working commit:
git reset --hard 867b7b9
- Create a new branch from this working state:
git checkout -b fix-bug
- Now you can cherry-pick the good changes from your
save-improvements
branch. To see what commits you want:
git log save-improvements
- Cherry-pick the good commits one at a time:
git cherry-pick <commit-hash>
If you run into conflicts during cherry-picking, you can:
- Resolve them manually
- Use
git cherry-pick --abort
to start over - Use
git cherry-pick --skip
to skip a problematic commit
This way, you can rebuild your timeline with just the good changes, leaving out the problematic one. Once you’re happy with the result, you can merge back to main and push.
Would you like me to help you through this process? We can start by identifying which commits you want to keep.
Digesting git
Cherry-Picking
I want to just push back on Claude immediately, asserting how that makes no sense and is incomprehensible to boot. But I decide to brute-force my way through the process, drawing the mermaid gitgraph diagrams in my head.
Me: This is where my mind blows up, Claude. I’ve done all this:
[mike@nixos:~/repos/botifython]$ git pull
Already up to date.
[mike@nixos:~/repos/botifython]$ git reset --hard HEAD
HEAD is now at 4ca8aee Created a get_timestamp helper function
[mike@nixos:~/repos/botifython]$ git reset --hard 867b7b9
HEAD is now at 867b7b9 Adjusted key look
[mike@nixos:~/repos/botifython]$ git checkout -b fix-bug
Switched to a new branch 'fix-bug'
…but now your instructions say this:
- Now you can cherry-pick the good changes from your
save-improvements
branch. To see what commits you want:git log save-improvements
- Cherry-pick the good commits one at a time:
git cherry-pick <commit-hash>
If you run into conflicts during cherry-picking, you can:
- Resolve them manually
- Use
git cherry-pick --abort
to start over - Use
git cherry-pick --skip
to skip a problematic commit
This way, you can rebuild your timeline with just the good changes, leaving out the problematic one. Once you’re happy with the result, you can merge back to main and push.
Understanding Git Branch Management
…but I just don’t get it! That is totally unclear to me. The mental model is just not in my head. Let me try:
I do a git pull
and git reset --hard HEAD
which ensures I’m at the very
newest that I’ve ever commit and push. And things are linear.
I create a branch named save-improvements
, but functionally things are still
linear as there’s not any changes in that branch. It’s a bud more than a branch.
However, the moment I do git reset --hard 867b7b9
, things are no longer
linear. In a mermaid diagram, there’d be a branching line. But it’s two lines
with identical histories.
The Mental Model Breakdown
Okay, so here’s where the mental model breaks down and I’m going to try to talk it out loud to get the answer for myself, then run it by you.
Now I check out a 3rd time git checkout -b fix-bug
from as you say, the
working state. We have 3 lines if it were a mermaid diagram (I really should
insert those). All 3 lines have shared histories, but two of the lines, main
and save-improvements
have the full history, bug and all. While the third
branch, was checked out after the git reset --hard <8675309>
and so is behind
in the horse race.
The Subtlety of Branching
WHILE I’M IN the bug-fix branch, I can still git log
a different branch
with git log save-improvements
. This is a subtle and nuanced point that
normally eludes me. I’m in the 3rd horse’s track who fell behind, but I’m
looking ahead in one of the two other currently identical neck-and-neck horses
out in the lead.
And now you say: Cherry-pick the good commits one at a time with commands like
git cherry-pick <commit-hash>
And I say whuuuuut?
The Patching Paradox
So this is all that merge stuff that I generally avoid in git due to my trying to stick to linearity for easier mental models. But there’s another world, a shadowy place where linear histories are decomposed into patches that can be plucked out of a timeline and selectively applied to other timelines without regard to causality.
The Time-Travel Dilemma
And I hate time-travel stories. I avoid them like the plague, especially in Star Trek. I mean like H.G. Wells with the Morlocks and Eloi is a must, but only because of historical context and the awesome metaphors it provides for those becoming too dependent on AI like me here, and the Luddites who stay on the surface. But that’s for another article. And I do have a time-travel prompt technique I use here a lot, but if you look at it, you’ll see it’s purely linear video-scrubbing style—not this multiverse crap.
Suffice to say, I need to embrace a kooky time-travel scenario of the sort I instinctively deplore in order to get the benefit of cross-branch cherry-picking, which is what we’re doing.
My question is why 3 branches: main, save-improvements and fix-bug.
fix-bug is immediately prior to the bug having set in. So conceptually we’re doing a Mario-style platform game jump over a particular commit.
We are in the fix-bug branch as we cherry-pick.
The Misunderstanding
We cherry-pick from the commit hashes found in save-improvements. But save-improvements is identical to main, since it was branched from main at the most recent branch HEAD (I believe the term is trunk). So this whole scenario is made more complex for me by increasing the surface are of the mental model by a third. This whole thing it seems could be done with just 2 timelines: the main and a rolled back one that cherry-picks off main.
What am I missing?
The Simplification
Claude: Ah, you’re absolutely right! I apologize for overcomplicating it. You don’t need the save-improvements
branch at all - it’s redundant since, as you noted, it would be identical to main
.
Here’s the simpler mental model with just two timelines:
gitGraph
commit id: "867b7b9 (working state)"
commit id: "bug introduced"
commit id: "improvement 1"
commit id: "improvement 2"
branch fix-bug
checkout fix-bug
reset id: "867b7b9"
You’re now on fix-bug
branch, which is at the working state (867b7b9). From here, you can look at main
’s history and cherry-pick the good commits:
git log main # See the improvements that came after 867b7b9
git cherry-pick <hash-of-improvement-1>
git cherry-pick <hash-of-improvement-2>
This is like saying “I want these specific changes, but not the bug that came between them.”
Once you’ve cherry-picked the good stuff onto fix-bug
, you can:
git checkout main
git reset --hard fix-bug # Make main match your fixed branch
git push --force # Rewrite history on remote
You’re right - the third branch was unnecessary complexity. It’s just:
- Main timeline (with bug)
- Rolled-back timeline that cherry-picks the good parts
Would you like to proceed with this simpler approach?
Paradox Patching
Me: No, I’ve already gone this route and I’m full of decision fatigue from being out of my comfort zone and being hit with 1/3 more complexity than I needed for figuring out something new. Just make a correction statement and help me apply a patch, such as it were. Maybe I need to delete a branch, and then I do the git logging against main from here forward?
Claude*: You’re right - let’s simplify and get back on track. Here’s where you are:
- You’re currently on the
fix-bug
branch (at the working state, before the bug) - The
save-improvements
branch was unnecessary - we can delete it:git branch -d save-improvements
- Now just look at main’s history to find the good commits:
git log main
- Cherry-pick the improvements you want, skipping the problematic commit.
That’s it - no need to overcomplicate it. Would you like me to help you identify which commits to cherry-pick from the main branch?
Me: Haha, okay so we’re paradox patching. So say something funny to lighten the mood. And this…
[mike@nixos:~/repos/botifython]$ git branch -d save-improvements
error: the branch 'save-improvements' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D save-improvements'
hint: Disable this message with "git config advice.forceDeleteBranch false"
Claude: Git is like “Hey, you can’t just delete your timeline without using THE FORCE!” 😄
Since we’re playing fast and loose with causality anyway, let’s use the -D
flag to tell Git “Yes, I know what I’m doing with these parallel universes”:
git branch -D save-improvements
Think of it as using the infinity gauntlet to snap away that unnecessary branch. Now we can focus on cherry-picking from main
without that third timeline making us question our grip on reality!
Would you like to see what commits we have to choose from? (No quantum mechanics required for the next step, I promise!)
Okay, that worked. This is a major no take-backs like improvement. Once I learn checkout’s cherry-picking in git, it’s a new superpower. It’s like getting bitten by the radioactive spider, but you have to go through spider-power pubescence.
So I delete the extra branch, if only to simplify the model
[mike@nixos:~/repos/botifython]$ git branch -D save-improvements
Deleted branch save-improvements (was 4ca8aee).
The o1 Pro Summation Before The Chery-Picking
I still haven’t cherry-picked and merged. This is my kryptonite. I like my comfy-zone. I don’t like venturing out of it. I develop muscle memory for something that works and I stick with it, squeezing a lot of juice from whatever lemon. And that’s actually pretty good aka the Unix way. But that means there’s a hump I sometimes need to get over when it’s important like this.
Me: Hey o1 Pro, exhaustively and in pedantic meticulous detail, please
document the key learnings I should take away from this. And don’t spare the
Mermaid Diagrams, but please make sure to mark them up in the <div
class="mermaid">
style, because that’s all I’ve been able to get Git Pages to
render, please and thank you!
o1 Pro: Below is a meticulous, pedantically-detailed summary of key Git lessons learned from your branching, resetting, and cherry-picking adventure, with Mermaid diagrams in <div class="mermaid">...</div>
blocks so Git Pages will (hopefully) render them properly.
1. Understanding “Linear” Time vs. Git “Branches”
1.1 Linear, Single-Path Thinking
Historically, you said you used git in a purely linear style:
- Make commits on
main
- If you want to revert changes, you do a
git reset --hard <old-hash>
- If you want to restore them, you re-check out
HEAD
or re-commit them
This approach is effectively a single, continuous timeline. You can jump forward or back by referencing commits (like pressing Rewind or Fast-Forward on a single track).
Benefits:
- Simple mental model
- “Scrubbing” forward/back in time
- Minimal merges
Downside:
- You cannot easily keep some changes while discarding others in the middle of the timeline without manual copying of files.
- “Dangerous” if multiple commits have good improvements and bad commits mixed in.
1.2 Git as a Multi-Timeline Universe
git
allows branching. In a “time-travel fiction” sense, multiple parallel timelines can exist:
- Each branch shares the history up to the common commit
- Then diverges to form a separate path
Basic Terms
- Branch: A named pointer to a commit in a timeline
- Checkout: Move your working directory and HEAD pointer to that branch or commit
- Reset: Move HEAD forcibly to a certain commit, adjusting your working copy
- Cherry-pick: Copy the changes from one commit (or commits) in another timeline into your current timeline
2. The Problem & The Proposed Solutions
Your goal: Roll back to a known good commit (call it 867b7b9
), but also keep the good improvements that happened after that commit—excluding the commits that introduced the bug.
2.1 You Could:
- Reset to the known good commit (thus discarding newer commits from your working directory).
- Branch at that point into a “fix-bug” branch.
- Cherry-pick the good commits from your old timeline (i.e. from
main
).
The final result is a new “clean” timeline that:
- Contains
867b7b9
as base - Has the commits you like from the old
main
- Omits the commits that introduced the bug
3. Step-by-Step Walkthrough
Let’s illustrate with Mermaid diagrams.
3.1 Original Single-Line History
Imagine your repository had these commits:
(Initial) --- (867b7b9: Good) --- (A: The Bug) --- (B: Improvement1) --- (C: Improvement2) --- (HEAD)
Mermaid diagram:
3.2 Rolling Back to Good Commit
You do:
git reset --hard 867b7b9
Now your local HEAD is at (867b7b9)
, effectively discarding commits (A)
, (B)
, (C)
from your working copy. However, those commits still exist in the repository’s timeline—you just can’t see them on HEAD.
Diagram becomes:
(Note: The commits after 867b7b9 are effectively “dangling” if you forced a reset, but you can still reference them by commit hash or by a branch name if one was pointing there.)
3.3 Create a Branch to Keep This “Rolled-Back State”
git checkout -b fix-bug
Now you have a new branch named fix-bug at 867b7b9 (Good)
. Meanwhile, main
might still be pointing to (C) HEAD
.
3.4 Inspect the Commits on main
Now from fix-bug branch, you can do:
git log main
to see commits (A)
, (B)
, (C)
.
3.5 Cherry-Pick The Good Stuff
Let’s say (B)
and (C)
are good improvements, but (A)
is the bug. You can:
git cherry-pick <hash-of-B>
git cherry-pick <hash-of-C>
Cherry-picking means: “replay the changes from commit B and commit C on top of fix-bug’s HEAD.” So your fix-bug timeline now looks like:
Now you have a brand-new lineage that includes only the “good” commits, ignoring commit (A)
.
3.6 Merge or Force-Push This New Timeline
Finally, you can make this new timeline the new main
:
git checkout main
git reset --hard fix-bug
git push --force
Now your main
is at a new linear path:
(Initial) --- (867b7b9) --- (B' ) --- (C' ) < main
Omitting (A)
from the official history.
4. The Freedoms & Dangers of “Rewrite History”
4.1 Freedoms
- You can remove commits you don’t like
- You can reorder or re-structure commits (by cherry-picking)
- You can keep a linear history
4.2 Dangers
- Force-pushing breaks public history for any collaborators:
- If others pulled the old
main
, they’ll have togit pull --rebase
or forcibly reset
- If others pulled the old
- Confusion if merges or new branches were made from the bug-laden commits:
- Everyone else’s references might still see the bug commit
5. Key Learnings & Best Practices
-
Git is Nonlinear
Even though you used it linearly, Git natively supports parallel timelines (branches). reset --hard
vs.revert
reset --hard <commit>
rewinds your local HEAD forcibly.git revert <commit>
makes a new commit that undoes the old one.- Because you wanted to keep some commits but not others,
reset + cherry-pick
is a valid approach.
- Cherry-Picking:
- Grabs a commit’s patch and applies it to your current HEAD.
- You can skip commits that introduced a bug.
- Potential merges or conflicts must be resolved manually if code overlaps.
- Branching for Safety
- Avoid rewriting
main
directly; create a branch (fix-bug
) from your working commit. - Cherry-pick the good improvements from your old
main
. - Force-push the new branch to
main
once you confirm it’s stable.
- Avoid rewriting
- Time Travel
- Git can appear complicated because it’s basically letting you do partial merges from any point in history.
- Mentally, it’s “copying patches from parallel timelines.”
- Deleting a Branch That’s “Not Fully Merged”
- If you truly don’t need it, use
git branch -D <branch>
to forcibly delete. - Or keep it if you suspect you might want more commits from it.
- If you truly don’t need it, use
- Document the Process
- Each “time-travel” operation can be documented in commit messages (like “cherry-pick fix for bug, skipping 123abc”).
- Keep notes to avoid confusion if you come back later.
6. Additional Mermaid Examples
To illustrate the final state after you cherry-pick commits B and C, skipping commit A:
In the diagram:
B’
is a new commit on fix-bug that replays B’s changes.C’
is a new commit on fix-bug that replays C’s changes.- The original
A, B, C
remain in themain
branch, but once you do agit reset --hard fix-bug && git push --force
onmain
, you effectively discardA
from the official timeline.
7. Conclusion
Key Takeaways:
- “Don’t Fear the multi-timeline approach.” By branching out, you can preserve or skip commits as needed.
- “Cherry-pick is your friend” for partial merges of only the commits you want.
- “Force-pushing rewrites the public timeline,” so use with caution (especially if there are collaborators).
- “Naming your branches help keep mental clarity.” E.g.
fix-bug
vs.main
vs. a leftoversave-improvements
. - The overhead is mostly mental, not so much computational. Once you get comfortable with the concept of picking patches from one timeline to another, it becomes a powerful superpower.
Final Words of Wisdom
Using git
in a purely linear style is a valid approach if you’re a solo developer. But branching and cherry-picking saves you from scenarios where you want to partially keep new commits while discarding others. The steps:
- Identify a known good commit → create or checkout a new branch from it
- Cherry-pick the commits you want from the other timeline
- Force-push (if needed) to overwrite
main
Now you have a curated, improved timeline without the bug commit.
You are unstoppable now that you can skip bad commits yet keep the best features!
Okay, I feel ready. First I assure myself I’m on a new git branch:
[mike@nixos:~/repos/botifython]$ git branch
* fix-bug
main
Imagine that things you can imagine you can suddenly now do. That’s what this is. That’s what’s happening here. Now, I probably could have figured out this git cherry-picking thing on my own just googling up a storm and reading all those stackoverflow posts, sorting fact from fiction and good advice from bad. But here I have… well, AI where I have to sort fact from fiction and good advice from bad, haha! AI ain’t gonna replace stupid anytime soon. No, rather it’s gonna learn from it and attempt a continuation of those stupid, but very mainstream loved by the flock concepts. Outliers, be warned.
Okay, that’s my last bit of rambling. If the advice I’ve received is accurate, I should be able to applying patches in the form of git hashes obtained from the git log command, jumping over the bad commit like Mario jumping over a barrel in Donkey Kong. 1377’s see…
Cherry-Picking Attempt #1, Clarification Needed
Me: The problem now is that I don’t see any newer commits than the fix-bug branch when I:
git log main
But I definitely do see the newer commits when I:
git log origin/main
But even after I:
git fetch origin
git log origin/main
…I still get only the same commits up to fix-bug when I:
git log main
Should I just cherry-pick from origin/main instead of main?
o1 Pro: Short Answer
Yes, you can absolutely cherry-pick commits directly from origin/main
rather than your local main
—especially if your local main
isn’t up-to-date. Cherry-picking from origin/main
is the most direct way to get those newer commits you see in the remote log.
Why Does This Happen?
- Your local
main
is stuck at the old commit (doesn’t have those newer commits). - Your
origin/main
on the remote has additional commits. - Doing
git fetch origin
does fetch them, but unless you explicitly merge or rebase them into your localmain
, they remain visible only as “remote-tracking” commits onorigin/main
.
Hence, git log main
still shows the older tip of your local main
, while git log origin/main
shows more recent commits that only exist on the remote side.
Two Ways To Resolve It
1. Cherry-Pick Directly from origin/main
While on your fix-bug branch, get the commit hashes from origin/main
, then:
git log origin/main # find the commits you want
# For example, you see commit hashes 123abc and 456def that you want
git checkout fix-bug
git cherry-pick 123abc
git cherry-pick 456def
Pro: It’s straightforward and works immediately.
Con: Your local main
remains behind. You might fix that later if you want your local main
to reflect the remote commits.
2. Update Your Local main
First, Then Cherry-Pick
-
Checkout local
main
:git checkout main
-
Merge (or rebase)
origin/main
into localmain
:git merge origin/main # or: # git rebase origin/main
- Now local
main
has those same newer commits.
-
Finally, checkout your fix-bug branch and cherry-pick from local
main
:git checkout fix-bug git cherry-pick 123abc git cherry-pick 456def
Pro: Keeps your local main
in sync with remote.
Con: More steps, merges or rebases can be a bit more confusing if you’re used to a simpler linear approach.
Which Approach To Use?
- If you don’t care about synchronizing local
main
right now, then cherry-pick fromorigin/main
directly. - If you eventually want local
main
to match the remote anyway, it can be cleaner to merge or rebase localmain
ontoorigin/main
first—then your fix-bug branch can cherry-pick from the localmain
.
Either way works fine. The gist is:
If you see commits on
origin/main
that you don’t see in localmain
, you can either update localmain
or you can cherry-pick straight fromorigin/main
.
Example Commands (Cherry-Pick From Remote Directly)
-
Find the commit hashes you want (e.g.,
abc123
,def456
) by:git fetch origin git log origin/main
-
Check out your fix-bug branch:
git checkout fix-bug
-
Cherry-pick them:
git cherry-pick abc123 git cherry-pick def456
-
Resolve conflicts if any. Then commit, or
--continue
.
Boom, done. Now your fix-bug branch has the “good” commits from the remote—skipping the problematic commit you didn’t want.
In Conclusion
- Yes, you can (and probably should) cherry-pick directly from
origin/main
if localmain
doesn’t show the newer commits. - Or update local
main
first if you want them to be in your local main branch.
Either path is valid. If you’re short on time, cherry-pick from origin/main
and worry about syncing local main
later!
What I Would Have Done In The Past
Me: Okay, I understand. In the past when I had a situation like that, I would:
git pull
git reset --hard HEAD
…which was just sort of like a heavy-handed smashing of the newest files into the workspace. I suppose this would work here, but those are the heavy-handed linear techniques I’m trying to put aside in favor of greater finesses. I almost never rebase because it’s done nothing but confused me with all the options in the past. But what you seem to be saying that if I do these 2 commands (ignoring the merge option):
git checkout main
git rebase origin/main
I would be accomplishing much the same thing, but now I’m using this checkout command instead of reset, so it’s more like the parallel timeline hopping we’ve been talking about than the video scrubbing? Given all this friggin article-writing I’m doing, clearly executing a few more commands isn’t a problem. What I want to do is get the mental models down so I can work with new git commands in my fingers in the future. I’m building my web shooters here. And so, I should flit about with checkout and get comfortable in the multiverse. Get the main branch up to the latest, then cherry pick off that.
o1 Pro: Short Answer
Yes, exactly! Doing something like:
git checkout main
git rebase origin/main
is akin to telling Git:
“Replay any local commits on top of what’s newly arrived on
origin/main
, so my localmain
is fully up-to-date (and has a linear history).”
That’s somewhat more graceful than git reset --hard HEAD
, and indeed it’s closer to the “branch-hopping” mental model than the “video scrubbing” approach. Afterwards, you can cherry-pick or merge from your local main
into whatever other branch you’re working on.
The “Video Scrubbing” vs. “Parallel Universe” Metaphor
- Video Scrubbing:
git reset --hard <commit>
= You drag the timeline slider in a single track, losing everything that happened after the point you’re scrubbing back to.
- Parallel Universe (Branch Checkout + Rebase):
git checkout main
=> Switch your local workspace to themain
timelinegit rebase origin/main
=> “Bring my localmain
up to date by replaying my local commits on top of the new commits from the remote.”
In short, rebase rearranges or “replays” your commits, allowing your local main
to appear as if it always had the remote’s updates first, avoiding merges that produce extra “merge commits.”
Why Rebase Instead of Reset?
reset --hard
can discard local commits if you’re not careful. It’s a brute-force “teleport HEAD to some commit.”rebase
tries to integrate changes in a more controlled manner, rewriting the sequence of commits so it’s a single continuous timeline.
For example, imagine you had:
(Commit A from remote)
/
(Commit X) -- (Commit Y local main)
- Now remote has a new commit (A) that you don’t have.
- With
rebase
, your commits (X, Y) will be replayed after commit A, so the final timeline isA → X → Y
. - With
reset --hard
, you might lose or smash over local X, Y, unless you carefully reapply them manually or do something extra.
“Few More Commands” vs. “Mental Model Gains”
You put it nicely: executing a few more commands isn’t a problem. Understanding the logic of how those commands integrate your commits is the real victory.
- Checkout = Move your workspace to a branch or commit (like stepping sideways into a parallel timeline)
- Rebase = Pull commits from one timeline, place them before or after your local commits, rewriting the order to remain linear
- Cherry-Pick = Grab specific commits from some other timeline’s history
It’s all in service of the “branch-hopping” style of Git usage, which unlocks a more flexible approach to merges and partial merges (selective picking of commits, skipping bug commits, etc.).
Final Thoughts
- Updating main with
git rebase origin/main
is definitely a more “Git-ish” approach than repeatedlyreset --hard
-ing your local to match the remote. - Then you can keep your fix-bug branch around, cherry-pick from a fully updated local main if you want.
- Or skip that entirely and just cherry-pick from
origin/main
directly—both approaches are valid.
But yes, you’re on the right path to building the mental model:
- Check out the branch you want to update.
- Rebase it onto or from the remote branch.
- Then do your partial merges (cherry-picks) or merges.
You’re building those Spidey-sense “web shooters” for multi-branch Git usage—and once you’re comfortable, you’ll leap from timeline to timeline with grace!
My First Multiverse Git Chery-Pick Jump
Me: Okay then. It worked! I have gotten my local main up to date with the remote one with these commands:
git checkout main
git rebase origin/main
And then I was able to look at all the latest commits with:
git log main
And then I was able to skip 1 of the 3 commits, and so I cherry-picked 2 hashes and didn’t cherry-pick the one where the bug was introduced, and I have confirmed that the bug isn’t re-introduced.
And so I sit here now on the fix-bug
branch and would like to… what? I
hardly ever do this. But I guess I would like to merge the changes I just did
back into the main branch, then switch again to the main branch and get back
into my linear way of working until I need to hop between parallel timelines
again, making causality-disassociated patches when I need, now that I have the
mental model, haha!
So what now, oh great o1 Pro big thinker?
o1 Pro: Below is a step-by-step plan for merging your changes from fix-bug
back into main
, then returning to a linear workflow. I’ll channel my o1 Pro big-picture persona but still provide the meticulous detail you love:
1. Confirm Your Current State on fix-bug
You’ve done your selective “Mario jump” over the buggy commit, so now your fix-bug
branch has:
- The good commits from
main
- Excluding the commit that introduced the bug
At this moment you are on fix-bug
, presumably with a clean working tree.
You can verify with:
git status
to see if everything is committed.
2. Decide How to Bring Changes into main
Two Basic Approaches
- Merge
fix-bug
intomain
(potentially creating a merge commit) - Rebase
main
ontofix-bug
(or vice versa) to keep the final history linear
Which to pick?
- If you prefer a more “linear” final history (no merge commits), you’d do a rebase approach.
- If you’re okay with a simple merge commit that says “Merge branch
fix-bug
”, do the merge approach.
If your main
is behind the changes on fix-bug
(that is, fix-bug
is effectively the advanced timeline now), a “fast-forward” merge might happen automatically, meaning no merge commit is even needed.
In other words, if main
is exactly at the old state (before the fixes) and fix-bug
is just adding commits ahead, Git might just fast-forward main
to fix-bug
.
3. (Option A) Merge fix-bug
into main
Without Rewriting History
-
Check out
main
:git checkout main
-
Merge from
fix-bug
:git merge fix-bug
- If
main
is strictly behindfix-bug
, this might be a fast-forward merge (no new merge commit). - If Git can’t fast-forward for some reason (like
main
has diverged?), it will create a merge commit.
- If
-
Push your updated
main
to the remote:git push
- If your remote main was behind or only had the bug commit, you might need
--force
if you’ve previously rewritten history. Usually a plaingit push
suffices if no rewriting happened. - If you did do a partial rewrite earlier, you might do:
git push --force-with-lease
to ensure remote is updated properly, but be cautious about rewriting public history.
- If your remote main was behind or only had the bug commit, you might need
-
(Optional) Delete the
fix-bug
branch if you’re done with it:git branch -d fix-bug
- If you get “not fully merged” warnings, use
-D
but usually in this scenario a standard-d
works after a successful merge.
- If you get “not fully merged” warnings, use
At this point, your main
is fully updated with your fix-bug changes. You can continue your linear workflow there.
4. (Option B) Rebase main
onto fix-bug
for a “Linear, No Merge Commit” History
If you want your final commits on main
to appear as if they were made directly there (no merges), you can:
- Check out
main
:git checkout main
- Rebase
main
ontofix-bug
:git rebase fix-bug
- This effectively “replays” anything new on
main
(if any) on top of your fix-bug commits, turning it into a single linear story. - If
main
was behindfix-bug
, you’ll just get those fix-bug commits stacked on top with no merge commits.
- This effectively “replays” anything new on
- Push:
git push
or, if you rewrote anything, you might need:
git push --force-with-lease
- Optionally delete the
fix-bug
branch:git branch -d fix-bug
End result: A single chain of commits on main
, exactly as if the commits from fix-bug
were originally developed on main
in the first place.
5. Resume Your “Linear Way of Working”
Once main
has the up-to-date code:
- Stay on
main
for your everyday commits. - If you find a new bug, you can do the same dance:
- Create a short-lived branch (like
quick-fix-bug2
) - Patch commits
- Merge or rebase them back into
main
.
- Create a short-lived branch (like
No big deal once you’ve done it a couple times.
6. Tidy Up & Celebrate
- Double-check your remote main branch on GitHub or wherever to ensure it has the final commits and no references to that bug.
- Optionally,
git branch -d fix-bug
locally. - If you had a remote branch for
fix-bug
, you can remove it with:git push origin --delete fix-bug
if you want.
Now you’re back in a single, linear timeline: main
with the commits you want, skipping the bug.
Final Words from o1 Pro
- You overcame the initial mental barrier of branching, skipping commits, and rejoining timelines.
- We parted ways with the “video scrubbing” approach for more advanced Git usage.
- You are free to code linearly on
main
again—until another situation arises where you need to hop to a parallel timeline and cherry-pick. But that’s the beauty: you can do it whenever you want.
So:
- Decide on Merge vs. Rebase approach
- Perform the relevant commands
- Push and confirm
- Delete fix-bug branch
And that’s it—no more bug. You’ve got your dev environment back to a normal routine with a single updated main
. Enjoy the renewed sense of calm—and relish your new “Spider-Man web-slinging” Git superpowers!
OMG WTF! How I Hate Multiverses & Time-Travel
Once you except either multiverse or time-travel (in anything other than time-dilation sense), all bets are off. That’s when I stopped following the X-Men in the 1990s and now they’re ruining the MCU with it too. Anyway, I’m not going to let it run
git
for me as well.
Me: Main has a bug! We don’t want main. We want what we just did with fix-bugs to replace main. No risk of merging or letting the bug-polluted version back. Don’t give me options. I feel decision fatigue in that specific flavor of knowing that the genie is just waiting to make your wish backfire on you. I explicitly want what I just did, which manifests correctly on the fix-bugs branch, to be the new main branch. I don’t care about the semantics surrounding it. I don’t want to wipe out my whole git history, but I’m fine with notes about the non-linearity that was involved in fixing.
o1 Pro: Below is a single, straightforward set of commands that will replace main
with whatever currently exists on fix-bug
, ignoring the old, bug-laden commits on main
. We won’t ask you about merging or rebasing. We will literally force main
to become exactly what fix-bug
is:
-
Make sure you’re on
main
:git checkout main
If
main
is not locally present (some weird scenario), just create it locally from origin:# ONLY if local main doesn’t exist: git checkout -b main origin/main
-
Force
main
to matchfix-bug
:git reset --hard fix-bug
This moves the
main
pointer (and your working copy) so that it exactly matchesfix-bug
.
No merges, no questions asked—your localmain
is nowfix-bug
’s content plus commit history. -
Push to the remote, overwriting remote
main
:git push --force-with-lease origin main
--force-with-lease
is a gentler variant of--force
that checks if the remote was updated by someone else in the meantime. If you’re absolutely sure no one else touchesmain
, you can do a plain--force
.- This tells the remote: “Discard whatever old commits were on
main
there and replace them with my localmain
pointer.”
At this point, remote main
has effectively become the content from fix-bug
, and you retain the prior history in your local repo (the old commits are no longer reachable from main
, but they still exist in “git land” if you needed them for forensic reasons).
Cleanup (Optional)
-
If you’re done with
fix-bug
as a separate branch, you can remove it:git branch -d fix-bug
-
If
fix-bug
exists on the remote, you might also remove it:git push origin --delete fix-bug
No merges, no confusion about polluting main
with a bug-laden commit. You fully replaced main
with the fix-bug timeline, while preserving all commits in the object database if needed later. You now have a “clean” main
that lines up with your fix-bug work.
Updating Git Main Branch With Cherry-Picked Fixes
Great success! Linearity is good. It might be an illusion, but it’s a useful, convenient thought-clarifying illusion. I keep trying concurrency in Python, but you know it’s telling that
async
andawait
are paired-up right out of the gate. You don’t have one without the other waiting on it. Concurrency is best when it’s a momentary, fleeting thing—bursty with aggregating the results ASAP back to a single thread. In my mind is the git equivalent.
Me: Okay, so what I did was this:
git status
git checkout main
git reset --hard fix-bug
git push --force-with-lease origin main
git branch -d fix-bug
And in looking at all the branches with:
git branch -a
Cleaning Up Remote Branches
…to make sure I got them all, I also noticed some other stray branches I had from experimentation in the past, and I looked up how to delete them from origin, and found:
git push origin --delete <branch name>
And so I deleted those too. Now I have only local main and origin/main, as it should be (right now).
Getting Back on Track
That takes care of my immediate needs with the Mario jump over the bug. I’ll revisit what I was trying to do there as I get back on track from this very worthy rabbit hole diversion of learning git better.
The Power of Local Branches
But also, I’ve been thinking about this. Complexity aside, this gives a smoother alternative to git cloning into another directory to look over past edits without collision or overwriting. You can do it in the same directory quickly with branching, in and out, the delete the branch when you’re done and go back to main.
Simple Branch Creation
So even without the cherry-picking, this gives some power moves. Let’s say you lost a feature that you knew you had before. This is really just simple branching. There’s nothing simpler than:
git branch <name>
…to make a new branch. No muss, no fuss. Just BAM! New branch right where
you’re working, without having to cd
around and clone into new directory
names. And the new branch really is a clone of whatever branch you were on
(pedantic semantics aside), I believe. So if you were on the HEAD of the main
branch, you have another up-to-date side-by-side parallel timeline.
Time Travel Within Branches
But then you could go fishing for changes just going back in time, scrubbing the way I’m used to with:
git reset --hard HEAD^
…over and over, going back in time. And if you have one of those editors that checks for file changes and automatically reloads it, you could just watch for the change to appear that you know disappeared at some point. Then you could copy it into your OS or nvim copy-buffer, switch back to your main branch with:
git checkout main
…and then just paste the new/old change in. Do I have that right? Then you can just go cleaning up old branches, which must be such a thing in the git realm that I just don’t think about a lot, because up until now I haven’t used branches much.
A Hybrid Approach
This is a hybrid approach to my old linear method of never leaving main, but scrubbing all the time, and the non-linear cherry-picking bizarro commit-patching method. If you can pinpoint a chunk of code, you can just carry it between branches with the OS or nvim copy buffer and be very explicit and literal about it, using familiar OS/Desktop mental models over Linux Torvald’s sci-fi filesystem reality.
Rummaging Around In Branches
I’m not totally sold yet on applying causally disassociated git commits as patches. I mean sure, I’ll eventually be doing it like it’s second nature, but I need a transition plan, something that fits it the traditional copy/pasting from temp folders model, but with branches instead!
o1 Pro: Absolutely—this is a completely valid approach and a nice middle ground between:
- Purely linear scrubbing (resetting repeatedly in one single timeline).
- Full-blown merges/cherry-picks across multiple branches.
By quickly spinning up a temporary branch (like “_scrub”) whenever you want to rummage around old commits, you can:
- Branch at HEAD of whatever line you’re on (e.g.,
main
). - Move HEAD around that new branch (e.g.,
git reset --hard HEAD^
, or a specific commit), letting you look at files from that point in history right in the same working directory. - Copy–paste (or compare diffs, or do anything else you need).
- Checkout your main branch again (e.g.
git checkout main
). - Potentially paste in the code you rescued or revert any changes you made.
- When done, delete that ephemeral branch (
git branch -D scrub-branch
).
No separate local clone directories, no merges or complicated cherry-picks—just a quick branch creation to roam around the timeline. Then you can bring what you found manually over to your “real” branch via copy/paste or partial diffs. This is effectively doing “manual cherry-picking” at a file or snippet level, rather than a commit level.
So you nailed it—branching can be used simply as a “scratchpad” for exploring older commits:
- Create a branch
- Reset that branch to older commits as desired (scrubbing backward in time)
- Look around, open old versions of the code in your editor
- Copy what you need
- Return to your main branch
- Paste in the snippet (or do a manual
diff
if you prefer) - Delete the ephemeral branch afterward
This preserves your usual linear “main” approach but still unlocks the speed of local Git branching to rummage through time. It’s a nice synergy between the older single-track method and the new branching comfort you just developed. Enjoy your new superpower—temporary branches for code archaeology!
Git Scrubbing Controls Clarified
I say I do the equivalent of video scrubbing with git. But not really. I just do backwards-by-1-only with
git commit --hard HEAD^
, that funny syntax having stuck with me once seeing it made fun of. Now I want the full details!
Me: Okay, so my big trick is to git pull
and then git reset --hard HEAD
and then to start going back one commit at a time with git reset --hard HEAD^
until I find the change I need, then I copy the change and git pull
and git
reset --hard HEAD
again then paste the change in.
I want to do the equivalent of that now in branching. So how do I easily travel
back and forward like git reset --hard HEAD^
lets you travel back by one step?
I want the travel-forward-by-1 equivalent so I can really do that back and forth
scrubbing, but then I want to be able to jump back to the head again in the new
branch. So the pattern is:
git branch _scrub
git checkout _scrub
git reset --hard HEAD^
git reset --hard HEAD^
git reset --hard HEAD^
…and then my questions to you. Jump forward by 1. Jump back to HEAD.
Scrubbing The Idea of Easy Git Forward-By-1 Scrubbing
o1 Pro: Short answer: Git does not provide a built-in “HEAD++” operation for jumping forward one commit the same way it provides “HEAD^” (or “HEAD~1”) for going back. Once you move HEAD backward, the forward commits are no longer referenced by HEAD^
–style syntax. Instead, you must identify the future commit by name (a branch name, a tag, a hash, or a reflog entry) and use git reset --hard <that-future-ref>
.
Below is a detailed explanation of how to replicate the “scrubbing back-and-forth” pattern in a temporary _scrub
branch (so you don’t do it on main
), plus two ways to jump forward again when you’ve moved HEAD backward.
1. The Basic “Scrubbing” Setup
- Create a scratch branch at your current HEAD:
git branch _scrub git checkout _scrub
This
_scrub
branch now points to the same commit as your main branch’s HEAD did. - Now you can jump backward (scrub backward) with:
git reset --hard HEAD^ git reset --hard HEAD^ # Repeatedly as needed
Each time you do
HEAD^
, HEAD moves one commit earlier in the_scrub
branch. - Look at your code, copy what you need (like you said). If you want to keep going backward, you do more
git reset --hard HEAD^
. This is fine—the commits in front of you haven’t truly disappeared; they’re just not pointed to by HEAD anymore.
2. Now You Want to Jump Forward by 1?
Once you’ve done git reset --hard HEAD^
a few times in _scrub
, the next commit “in front” of you is no longer at HEAD^
. That reference was ephemeral: once HEAD moved, “HEAD^” will refer to something different.
Options:
Option A: Use git reflog
to jump forward
- Run:
git reflog
That shows a list of recent HEAD states with references like
HEAD@{0}
,HEAD@{1}
, etc. For example, you might see:239afde (HEAD -> _scrub) HEAD@{0}: reset: moving to HEAD^ ... 552b2e0 HEAD@{1}: checkout: moving from main to _scrub ... 552b2e0 HEAD@{2}: commit: ... ... 9cc6f4b HEAD@{3}: ... ...
- Identify the commit you want to jump forward to in that list.
- If you see your future commit at something like
HEAD@{5}
, you can:git reset --hard HEAD@{5}
That effectively does “move HEAD forward” to where it was 5 steps ago in the reflog.
- If you see your future commit at something like
Pros:
- Straightforward, once you know how to read
git reflog
. - Perfect for ephemeral “I just want that HEAD from 1 minute ago.”
Cons:
- The numbering in
HEAD@{n}
is chronological (“n steps back in time I was at that commit”), not purely a linear commit count. If you do more resets or checkouts, the reflog changes quickly.
Option B: Jump to a Specific Commit Hash or Branch
If your “future commit” is known by some hash or a branch name, you can do:
git reset --hard <commit-or-branch-name>
- If
_scrub
once pointed to commitabc1234
, you can dogit reset --hard abc1234
- If you had never left
_scrub
in the first place and_scrub
still pointed to that commit, then you might do agit checkout _scrub
again.
Important: In your scenario, _scrub
moves with you as you do git reset --hard HEAD^
, so the label _scrub
always references your new HEAD position. That’s why the original “forward” commit is no longer _scrub^
; _scrub
is the tip of your branch now.
3. Return to the “Newest” Commit in _scrub
If your entire purpose is “scrub backward a few steps, rummage around, then snap forward to the branch’s original HEAD,” you can do something like:
- Tag or reflog the original HEAD before going backward:
# first, name your original tip for re-forwarding git tag scrub-start HEAD # or store a reference: original_head=$(git rev-parse HEAD) # get the commit hash
- Scrub back:
git reset --hard HEAD^ git reset --hard HEAD^ # rummage, copy code, etc
- Jump forward again:
# If you used a tag: git reset --hard scrub-start # If you used $original_head from earlier: git reset --hard $original_head
This “bookmarks” your future point so you can easily jump forward again with a single
git reset --hard
.
4. Summarizing the “Scrubbing in a Branch” Pattern
- Branch at HEAD:
git branch _scrub git checkout _scrub
- Optionally tag this HEAD so you can get back to it easily:
git tag scrub-start
- Scrub back:
git reset --hard HEAD^ # or HEAD~2, etc # repeat as desired
- Look around, open your editor, copy code.
- If you want to “move forward by 1 commit,” use reflog or a tag:
git reset --hard scrub-start
git reset --hard HEAD@{5}
- When done with rummaging, check out your real branch again or merge changes:
git checkout main # or maybe manually copy your code changes and paste git branch -D _scrub
(Remember that if you do partial edits in
_scrub
, you might want to commit them or copy them out before blowing it away!)
5. Rely on Manual Copy or Diffs to Transfer Code
As you noted, the scrubbing is typically to view older commits, then copy & paste the lines you need. That’s simpler than “cherry-picking partial commits.” So you can do:
- Scrub in
_scrub
until you see the code snippet you want. - Copy it at the OS or editor level.
- Checkout your main branch.
- Paste those lines into your current code.
- Done.
_scrub
is ephemeral.
Final Words
Git doesn’t have a direct “move HEAD forward 1 commit” because once you move HEAD backward, there’s no inherent pointer to “the next commit.” Instead, you must:
- Either use reflog to find the previous HEAD states (like “HEAD@{2} from 5 minutes ago”)
- Or use a known branch/tag name or commit hash
That’s the entire trick. The rest is just the ephemeral “_scrub” branch and ephemeral resets. Then manually copying the old code snippet to your real branch is perfectly fine (and often simpler than merges or cherry-picks). Enjoy your new “scrubbing in a branch” approach!
So You’re Telling Me There’s No Rewind?
Is
git commit --hard HEAD^
really a 1-way proposition, just like a SQL forward-only firehose curser? I can sort of believe it because I implemented something much like that very recently in pipeline reverts! But I gotta confirm.
Me: Haha, wow! So you’re telling me that going backwards in a git branch
with git reset --hard HEAD^
is as permanently committal and undoable as the
revert buttons in the Pipulate Pipeline? Or forward-only firehose cursors in
SQL? And you’d have to use tagging to jump back to the HEAD? There’s no quick
reset like I used to do with a quick git pull
followed by a reset to HEAD?
Wow, I didn’t realize how clever my linear scrubbing go-back-to-HEAD technique
really was, making use of the remote origin that way. Are you sure there’s not
some shortcut for go-to-HEAD again? I mean if you can tag it and do it that way,
surely there must be some equivalent the way Linux gives you ~/
for home.
Yeah No Kidding. I Need To Learn Git Better!
I advocate Linux, Python, vim & git (the LPvg stack) all the time, but truth is some of these go so deep, I’ve never taken the full journey yet. And this is certainly the case with
git
. And so mark this as the beginning of that adventure. I’ll be seeking out more git learning resources from here on out.
o1 Pro: Short Answer
There’s no built-in “HEAD++” or “go-to-HEAD-again” command in Git once you’ve done git reset --hard HEAD^
. You must specify a commit/branch/tag (or use the reflog) to jump forward again. If you want a “bookmark” for returning to the original tip, you have to manually create it—by tagging, stashing, or referencing the reflog.
Below is a deeper explanation:
1. Why “HEAD++” Doesn’t Exist
- HEAD in Git is just a pointer (reference) to a single commit.
- When you do
git reset --hard HEAD^
, you say “move HEAD pointer to the parent commit.” Now your new HEAD points to that earlier commit. - The “commit that used to be HEAD” is no longer HEAD’s “child.”
- There’s no default pointer to the “next commit” after you go back. That next commit is basically “orphaned” from your HEAD pointer’s perspective, unless you have another branch or a tag that references it.
Example:
(HEAD) old HEAD
↓
commitX ---- commitY ---- commitZ
↑
HEAD after reset --hard HEAD^
Once you reset HEAD to commitY, Git just sees commitZ as “unreferenced” (unless some branch or tag is pointing there).
2. Using Reflog (HEAD@{…})
If you want to jump forward again to the commit you had before, you can look it up in the reflog:
git reflog
# find the line that says "reset: moving to HEAD^" or "checkout: moving from ..."
# look at the commit for HEAD@{1} or HEAD@{2}, etc
git reset --hard HEAD@{2}
This basically says: “Go back to how HEAD was 2 steps ago in my local history.” That’s your closest equivalent to “HEAD++” in Git, but it’s time-based, not strictly in a child-of-HEAD sense.
3. Tagging or Stashing as a “Bookmark”
A simpler approach if you know you’ll want to jump forward again:
- Before you do
git reset --hard HEAD^
, tag the original HEAD:git tag original-head
- Then do:
git reset --hard HEAD^
- To jump forward again:
git reset --hard original-head
Or similarly:
git checkout original-head
That’s effectively your manual “go-to-HEAD-again” command.
Alternatively, you can do a quick stash:
git stash push -m "bookmark"
git reset --hard HEAD^
# rummage around
git stash apply stash@{0}
But stashing is more for uncommitted changes. Tagging or referencing the commit hash is more direct for commits.
4. The Remote-Branch Trick vs. Local-Only Branch Trick
4.1 Your Old “Linear” Trick (Local <-> Remote)
- You’d do
git pull
so thatorigin/main
points to the newest commit. - Then do
git reset --hard HEAD^
(locally) as many times as you want. If you want to jump forward, you can do:git reset --hard origin/main
Because the remote pointer
origin/main
is effectively your “bookmark” for the newest commit. This is often how you “scrub forward” in your linear approach.
Why That Works:
origin/main
is another pointer that still references the tip.- So “pull” sets
origin/main
to the latest. Then you do local resets. When you want to re-jump to that “future,” you just “reset –hard origin/main.”
4.2 Local Branch Approach
- If you’re on a local branch named
_scrub
, there is no built-in “_scrub@{++}” to jump forward. - You’d have to reference a commit by tag, by reflog, or by some other branch pointer that still references the newer commit.
Hence: No “magic HEAD++.” Tagging or using the remote pointer accomplishes the same effect.
5. Summary
- Yes, your “linear scrubbing go-back-to-HEAD technique” is basically relying on a second pointer (
origin/main
) to jump forward. - If you do purely local scrubbing in a single branch (with no second pointer), you need to create that “bookmark” yourself (a tag, stash, or reference the commit hash via reflog).
- No single command is built in to do “HEAD++.” Instead, “HEAD++” is just “go to some known commit,” which you must name with a branch, tag, or
HEAD@{n}
reference.
Hence your old approach was clever indeed, using origin/main
as a “~/home.” But if you’re purely local with a scratch branch, you just have to manually bookmark it if you want a quick re-jump.
Conclusion: The Git Journey Continues
Alright, I’m done with this git rabbit hole for now, except for the fact that I’ll never really be done with it from now. I have taken the red pill of branching and checkouts, and I must get my practice in and get it into my fingers. Git is one of those “get it into your mental model of the world” foundational sort of tools, one that touches everything — a swiss army knife that’s so much more than the one thing it’s touted for (being a distributed version control system, aka DVCS). Yet its really so difficult to get a handle on.
Overcoming Git Hesitation
I’ve deferred this branching and cherry-picking challenge for years, never really being up to it. But I’m next-leveling myself everywhere, an I’ve been keenly aware of how much I’ve been handicapping myself with my linear treatment of the git model. Today was the straw that broke the camel’s back because of a humble bug I introduced and didn’t realize it until I was a number of commits in.
Worth The Investment
Yes, it’s yet another rabbit hole. But it’s one that passed the rabbit hole evaluation test. It gave me permanent forever-forward superpowers with a relatively small investment, all things considered. And given my ability to actually learn it, it was one of those success assured sort of things, because I’m not out on the bleeding edge with this the way I am with FastHTML/HTMX… or Anywidget… or NixOS… or Nvida+Wayland… sigh. How can I claim to detest the bleeding edge so much and be so firmly entrenched in it?