Thoughts in progress: jj and my homelab

My jj experiment is going well

As mentioned, I’ve been trying jj lately, and I’m beginning to see the light. Or at least some of it. Here are a few key changes I’ve noticed.

Rebasing is easy and fun

With git, I generally avoid rebasing other than simple workflows, like pulling the latest state of whatever branch I’m working on and rebasing my local changes on top of it. I don’t enjoy rebasing with git. It feels uncomfortable and risky. Admittedly, I’m not super experienced at it, and I have some emotional scars from failing at complicated rebases in years past.

With jj, on the other hand, rebasing has become an easy, default, and almost joyful way of working.

Here’s an example. Let’s say I write an almost-final blog post and commit it. Then I see a typo and fix it.

With git, I’d put that typo fix in a new commit. That’s what typical git UIs are set up to do most easily. But this makes for a noisy commit history. For example, this blog’s commit messages often look like this:

  1. “wip: jj-thoughts post”
  2. “fix typo”
  3. “publish jj-thoughts”
  4. “remove stupid line”
  5. “oops”
  6. “add more words”

(If I were collaborating with anyone else in this repo, I’d try harder.)

With jj, it’s easy to keep building up the same commit with subsequent changes. In most cases, all I do is:

jj squash

That command adds my pending changes — that typo fix, for example — to my earlier commit that has the rest of the blog post.

That was a rebase. It’s that easy. And it makes the commit history above more likely to look like this:

  1. “add post: jj and homelab thoughts” ← the example commit history above is collapsed into just one, tidy commit
  2. “refactor template layout to accommodate new notes template” ← I’ve moved on and committed a distinct batch of work

There’s a lot more to explore with jj’s rebasing. I’m highlighting the bare squash command because it’s the one I use the most right now, and I super dig it. It lets me commit early and often but still keep my commit history tidy. It’s also helping me to understand the basics of jj, building my confidence to try more advanced maneuvers.

That’s part of what’s fun about jj: it’s forcing me to think about version control in new ways and try different workflows. And it feels safe to experiment, because if I get out over my skis, jj undo is there to rescue me.

But git can do that too

Yes, to be fair, git can do anything that jj can do. For example, this will do something similar to jj squash:

git commit -a --amend --no-edit

But despite having used git for 15 years, I wouldn’t have been able to guess that command. I’ve used git commit --amend lots of times but only to rewrite a commit message. I learned the command above by asking Gemini to give me the git equivalent of jj squash.

jj makes it easy to do many things that are possible in git, but may be toilsome or risky for the non-expert. For example, I never thought I’d describe rebasing as “easy” or “fun.” Yet here I am, doing just that.

I’m pushing less often

Rebasing with jj is so nice and easy … until you push. If you try to edit a commit that’s been pushed, you’ll see something this:

Error: Commit 8b91be51e0c9 is immutable
Hint: Could not modify commit: wskwnqpq 8b91be51 main* | add note on stephenson's volume
Hint: Immutable commits are used to protect shared history.

And this makes sense. Once you push something, someone else might be relying on it, so you shouldn’t go changing it willy nilly.

With git, I commit and push very frequently. But now that I enjoy rewriting history with jj, I’m reluctant to lock it by pushing.

So I just push less often. What are the consequences of that? First, if I stop pushing as an automatic reflex, I might forget to push altogether. Then, if I try to pick up where I left off on another machine, the latest changes won’t be available on the remote. I’ll be stuck.

Second, if computer suddenly goes up in flames, I might lose work.

To address both of these concerns, I’ve decided to (1) do pretty much all my jj work on my homelab over remote SSH rather than continually syncing changes across computers, and (2) finally get backups set up on said homelab. More on this below.

Pushing to main is less straightforward

My use cases for git typically involve lots of pushing and pulling to the main branch to keep my stuff backed up and sync’d across my machines. Replicating that workflow with jj’s version of branches, called bookmarks, is a little less simple.

After some fumbling around, I think I sorta get it now. But I’m dodging the issue for the moment and using this command to keep the remote main branch up to date:

git push origin HEAD:main

As I understand it, this ignores jj’s bookmark management and just pushes your current work (HEAD) to main.

I added it as an alias to my jj config like so:

[aliases]
pushtomain = ["util", "exec", "--", "git", "push", "origin", "HEAD:main"]

So, for now, I use jj pushtomain whenever I want to update GitHub.

Do I even need GitHub?

For a hot minute there, I thought I might use my homelab as my primary git remote and relegate GitHub to a secondary/backup remote, or perhaps do away with GitHub entirely. But then I realized that if my homelab becomes unavailable while I’m on the road (say, due to a power outage or my ISP dropping out), I’m SOL.

So GitHub stays.

Homelab

For the reasons above, I’m doubling down on my homelab.

VSCode server

When running VSCode’s Remote SSH extension, VSCode installs a headless server on the remote SSH host. That server does a lot of the heavy lifting with LSPs, extensions, debuggers, whatever I run in the my terminals (like the 11ty debug server), etc.

In theory, that means my laptops are doing less work and enjoying longer battery life. Gemini tells me that this rig reduces local CPU consumption by 70-95% and memory consumption by 40-70%. I have done absolutely nothing to verify those numbers, but they sound cool.

I’m currently using a multi-root workspace to keep all my projects open in one window. It’s weird and handy.

BorgBackup

I setup Borg to backup my ~/projects directory. It backups to two destinations:

  1. a local one on my homelab machine, and
  2. a remote one on BorgBase.

I created a systemd service and timer to run the backup hourly.

backup script

I wrote a light wrapper around Borg that helps perform the backups, list them, validate them, etc. So I can do stuff like:

# do a quick, local backup
backup now

# backup to all repositories
backup all

# validate the latest backups
backup check

The check command will mount the latest backup to a location on disk and spot-check a handful of backed-up files against their local counterparts to make sure they look the same. Seems to work.

mstuffs script

I have two important things running on my homelab now: the Borg backups, and the headless Obsidian sync service. I want to easily get the status of these things to make sure they’re working.

I wrote a script called mstuffs[1] that checks my custom systemd units and checks to make sure my latest Borg backup is less than an hour old.

It looks like this:

> mstuffs
borg.timer: active
borg.service: inactive
obsidian-sync.service: active
[borg] 2026-07-27 18:16:20,735 [INFO] ✅ Age of latest backup for repo /home/sal/backups/borg: 0:00:33.734995

But with some fancy colors to make good/bad statuses more obvious.

Then, in config.fish, I check whether I’m logging in via SSH and, if so, run mstuffs. That looks like:

if set --query SSH_CLIENT
    if test "$hostname" = "$homelab_hostname"
        mstuffs
    end
end

Well I’d say this post is plenty long, so I’m gonna call it.

Byeeeee 👋


  1. G.O.B. calls his stuff “m’stuffs” in some Arrested Development episode. ↩︎