Git-branchless: efficiently manage topic branches without switching branches

https://git.sr.ht/~krobelus/git-branchless

1 Like

Very interesting! This looks like it captures the essence of how I want to use git.

I’m trying it out on a throwaway git repo I initialized somewhere in /tmp and I’m a little surprised that @{upstream}.. is hard-coded, not only as a default but as the only reference point. If it was possible to change this as an argument the tool would be more flexible.

When using parents the commit message from the parent includes the [topic]. Also the topic branch is not based on its parent but a copy of it (necessarily so because the commits are different due to the different commit messages) but I would expect it to be from that commit.

Yeah, I’ve been meaning to add a parameter for this, I just never needed it.
I think I’ll add a -r/--range parameter, then it can even be used on branches that are not checked out. EDIT: done

1 Like

Those are good points. I used to have an option to drop the [topic] tags also from parents.

Usually though, I want the tags, to show that the parent commits are from somewhere else (and should not be reviewed here). It might be right to re-add the option, but I’d like to find a more flexible solution. Currently git revise --ref branch-name -i can be used to rewrite messages in that branch, but that’s not ideal either.

With trimmed commit messages, it should branch off the parent, but not when there are multiple unrelated parents. I don’t know what would be the benefit of that, less visual clutter, I guess.

There is also Stacked Git, I haven’t tried it yet tho.

2 Likes

@krobelus – is most of your workflow single developer?

1 Like

No, not anymore. I wrote this because I want to create a branch for every change, but still be able to work on all changes simultaneously.
I also merge others’ branches into it, because that makes it easier for me to review and test.

Ah multiple parents, I suppose in that case the right thing to do is what happens now with single parent.

I realised one interesting thing that could be done with this setup is to suggest parents for topics based on merge conflicts. For a topic that cannot solely be branched of from the reference point the program reports which topics it can be branched from and suggests those as parents. This way you don’t need to figure it out yourself :stuck_out_tongue: Complexity to check this for every topic is just quadratic and given how fast git-revise seems this could be viable.

Yeah, automatically detecting dependencies can be nice, so that’s definitely on the roadmap. Merges are done with git merge-file on temporary files, so when that fails, the tool can figure out missing dependencies. Checking which other commits modified that file could already go a long way. The next step is to run git blame on the lines around the conflict, that can usually detect at least one missing commit.

I’ve re-added the option to always trim the topic tags, I guess it can be useful. Though I use branches with unmerged dependencies only as WIP, so the topic tags are a feature.

I took the opportunity to try this out on a project at work and it was really pleasant to separate commits to different branches, one for a backend PR and one for a frontend PR. I will continue using this, very pleased with the workflow. I’m happy that you added trimming the topic flags. I’m not sure which option I will use more often but I think both can come in handy. Thank you for sharing!

I’ve added naive dependency hints: when a commit fails to apply, it lists all commits that touched the conflicting file and that are not among the dependencies.
This comes at the cost of depending on an unreleased version of git-revise, see the updated installation instructions.
The output format is fairly ad-hoc but usually it’s good enough.

The git workflow outlined in this blog post is what one can get with git-branchless:

Very cool article, thanks for sharing! The “Case Studies” are really compelling, they describe exactly why I use this workflow.

How do I get up to date when someone pushes to a “branchless” branch I published? My git-fu is very lacking.

Say I push a single commit as a branch, [my-branch] abc to origin/my-branch and now someone else pushes a random commit to that branch, usually they also merge master into that branch, cause why not.
So I pull the branch into my local branch, and do not want to end up force pushing the branch back out, but when I commit [my-branch] def on master and try git branchless, it gives me “error: generated branch my-branch has been modified. Use --force to overwrite.”`

I tried git branchless-pick with some arguments, but couldn’t really figure it out – I couldn’t get into a state where master would have the same commits as the branch, with all the [my-branch] stuff, and where I could do git branchless without --force again.

Right, the tutorial does not really cover that. TL;DR is that you need --force in this case.

I added --force as a safety feature, instead of silently overwriting branches. It’s not strictly necessary, since the reflog remembers old versions of each branch, but the explicitness might be helpful, I’m not sure?
When a branch is exclusively managed by git branchless, you will never need --force. Whenever git branchless creates a branch, it caches the branch’s SHA. The next git branchless invocation checks if the SHA actually matches the branch. If it doesn’t, someone likely modified the branch. This means the user has to decide which version to use.

Below is a list of commands you can run one to reproduce the “standard” workflow where you never leave the branch. After that I’ll describe what’s different when checking out the topic branch as you describe.

Essentially, if someone else pushed the “latest” version of a branch, you can use git branchless-pick @{u}..origin/my-branch:

#!/bin/sh

set -ex

tmpdir=$(mktemp -d)
cd "$tmpdir"

git init --bare the-repo.git -b master

git clone the-repo.git my-fork

(
	cd my-fork
	git commit --allow-empty -m "initial commit"
	git push

	# Create "my-branch" with one commit and push to "origin/my-branch"
	git commit --allow-empty -m "[my-branch] my commit wth a typo"
	git branchless
	git push origin my-branch
	git log --all --graph --oneline
)

git clone the-repo.git their-fork
# Someone else pushes to "origin/my-branch"
(
	cd their-fork
	# They could use "git branchless-pick" here, but that's for later.
	git checkout my-branch
	git commit --allow-empty --amend -m "my commit (fixed typo)"
	git commit --allow-empty -m "their commit"
	git push -f # "origin my-branch" is implied
)

(
	cd my-fork
	git fetch

	# "my-branch" has been updated upstream.
	# This command throws away local "[my-branch]" commits and replace
	# them by the upstream commits.
	# The argument must be a two-dotted range;
	# "A..B" means "all commits in B, minus the commits in A".
	# Instead of "@{upstream}" you can also write "@{u}" or something like "origin/master".
	git branchless-pick @{upstream}..origin/my-branch

	# This will let you edit the steps of the interactive rebase. This
	# is useful if you want to reorder the commits, but usually you can
	# leave it. The rebase-todo list will look like this:

	# # This drops your old commit.
	# drop be5c0de [my-branch] my commit wth a typo # empty
	# # Pick the new version of your commit.
	# pick 9b3a3b0 [my-branch] my commit (fixed typo)
	# # Added the "[my-branch] " prefix to the commit message.
	# exec GIT_EDITOR='perl -pi -e "s{^}{[my-branch] } if $. == 1"' git commit --amend --allow-empty
	# # Same for the new commit.
	# pick 56134e1 [my-branch] their commit
	# exec GIT_EDITOR='perl -pi -e "s{^}{[my-branch] } if $. == 1"' git commit --amend --allow-empty

	# Now we can update our local branch.
	git branchless
)

If both upstream and you modified the same branch, then the best workflow is probably to check out the branch, as you describe. Though I don’t think this should be a common scenario, usually only one person should be actively pushing to a topic branch.
If you checked out my-branch, and pulled, then your local my-branch contains the latest version, but your local master doesn’t. Then you pick the latest commits from local my-branch, which works the same way as from origin/my-branch.

In my example, this means to replace the last group of commands with this:

cd my-fork
git fetch
git checkout my-branch
git pull origin my-branch
git checkout master
git branchless-pick @{upstream}..origin/my-branch
git branchless --force

We have to use --force here because we changed the created branch manually.
In this case that’s maybe a bit paranoid because the contents/history of my-branch don’t change at all because we just cherry-picked the branch, and then want to regenerate the branch based on exactly those commits. However, git branchless doesn’t know what you did, it just sees that someone modified the branch, so the current behavior seems safe.

You can observe that git branchless --force didn’t change anything here by comparing the last version of the branch with the current one:

$ git range-diff my-branch@{1}...my-branch
1:  d90071e = 1:  0cf355c my commit (fixed typo)
2:  2160296 = 2:  87dd62e their commit

(so only commit SHAs changed).


I have thought about rewriting git branchless-pick in Python, for a more consistent CLI, but having two independent implementations (Python/shell script) forces the data format to remain simple, which is nice. I’ll add man pages eventually…

Is this a bug? My branch names are like [feature/name/topic-name] msg, and branchless-pick seems to always drop the feature/ from the beginning, ending up with [name/topic-name] msg, and I think that’s then making it not drop commits that were supposed to be dropped(?).

This is so you can use branchless-pick to integrate origin/some-branch as some-branch, which is the better name for the local branch which git branchless will create.

I have changed this to only remove the feature/ part if feature is actually a valid Git remote, so this should be fixed now for most realistic cases.

2 Likes