Skip to content
Automated Fixes for My Failures

Automated Fixes for My Failures

2026-05-19

I constantly open a repo in my terminal and start making changes and then rebase at some point when I realize that I never ran jj git fetch and now I have divergent changes, conflicts, etc. At some point a reasonable person stops repeating the action because they learn from their mistakes. To me, it seems like a decent reason to automate the problem away.

I might as well do some overengineering it while I’m at it.

The concept was fairly simple: run jj git fetch --all-remotes in every jujutsu repo under ~/dev/ on a schedule, without me involved. A systemd user timer felt like the right shape — it runs independently of any shell session, survives reboots, and integrates with system logging out of the box. The implementation ended up being a justafiable excuse to use pkgs.writeShellApplication, which I like using whenever I can (I don’t know why). It’s good tho. I’m going to tell you about it.

The Timer Is the Easy Part

systemd user timers in home-manager are not bad. Two blocks in home/modules/jj/default.nix:

systemd.user.services.jj-git-fetch = {
  Unit = {
    Description = "Fetch all jj repos under ~/dev";
    After = ["network-online.target"];
  };
  Service = {
    Type = "oneshot";
    ExecStart = "${fetchScript}/bin/jj-git-fetch-all";
  };
};

systemd.user.timers.jj-git-fetch = {
  Unit.Description = "Periodic jj git fetch for all repos under ~/dev";
  Timer = {
    OnBootSec = "5m";
    OnUnitActiveSec = "15m";
    Persistent = true;
  };
  Install.WantedBy = ["timers.target"];
};

OnBootSec = "5m" gives the network a moment to exist before trying to do something. OnUnitActiveSec = "15m" fires every fifteen minutes after that. Persistent = true is probably not absolutely necessary in this case — if the laptop was asleep at the scheduled time, the timer will catch up on next boot (since this runs every 15 minutes, it’s not really saving us much here).

fetchScript is a let binding pointing at the shell script package, which we’ll get to. Spoilers…

Look at my package!

So, writeShellScript is a file, but writeShellApplication is a package. Here’s where things get interesting. Nix gives you two primitives for packaging shell scripts:

pkgs.writeShellScript produces a single derivation containing a script file. That’s it. You get a path you can reference. You’re responsible for making sure every tool the script calls is available at runtime, either by hard-coding store paths or hoping the environment has them. There’s no build-time validation. If the script has a syntax error or calls a tool that isn’t in PATH, you find out when the script fails at runtime. Change your path later and forget about the script? Yeah, you get an error whenever that script runs… yay!

pkgs.writeShellApplication produces a proper package with a bin/ subdirectory. When you build it, Nix automatically:

  1. Prepends set -euo pipefail — so the script fails fast on errors, unset variables, and broken pipes without you remembering to do that
  2. Runs shellcheck at build time — if your script has a bug, nix build fails, not the systemd unit three days later
  3. Manages runtimeInputs — tools listed there are on $PATH inside the script, resolved to their store paths automatically, no hard-coding needed

The runtimeInputs pattern is quite pleasant. Instead of writing ${pkgs.fd}/bin/fd and ${pkgs.jujutsu}/bin/jj inline, you declare them:

# home/modules/jj/fetch-script.nix
{pkgs}:
pkgs.writeShellApplication {
  name = "jj-git-fetch-all";
  runtimeInputs = with pkgs; [
    curl
    fd
    systemd
    jujutsu
  ];
  text = builtins.readFile ./jj-git-fetch.sh;
}

And then the script itself just calls fd, jj, curl, systemd-cat — short names, no store paths, the way you’d write a script for any system (and makes importing scripts really seamless). The derivation takes care of resolving them.

I split the package definition into its own file (fetch-script.nix) and referenced it from default.nix via import ./fetch-script.nix {inherit pkgs;}. Simple, not callPackage — the package only needs pkgs, so pulling in the full overlay machinery would be overkill.

Finding my point (and some repos)

The script uses fd to discover the appropriate repos by looking for .jj/ directories:

repos=$(fd --type d --hidden --no-ignore '^\.jj$' "$HOME/dev" 2>/dev/null | while read -r jjdir; do
  dirname "$jjdir"
done)

fd '^\.jj$' finds directories named exactly .jj (regex anchored at both ends), anywhere under ~/dev/, including nested paths. dirname walks each result up one level to get the repo root. This handles repos at any depth without knowing the naming convention in advance — a flat ~/dev/foo/, a grouped ~/dev/work/client/project/, whatever.

The alternative would be a glob like ~/dev/*/, which only finds repos one level deep and relies on a consistent directory structure.

I could probably collapse the whole discovery-and-fetch loop into a single fd invocation using -x to run jj git fetch once per result — something like fd --type d --hidden --no-ignore '^\.jj$' "$HOME/dev" -x jj --repository '{//}' git fetch --all-remotes. Cleaner, no while loop, no intermediate variable. I didn’t feel like debugging fd’s placeholder syntax at the time, and the loop works fine. I’m not really doing this to learn something today.

The Network Check: Good Enough

Before doing anything, the script checks whether GitHub is reachable:

if ! curl --silent --head --fail --max-time 5 https://github.com > /dev/null 2>&1; then
  echo "jj-git-fetch: network unavailable or github unreachable, skipping" | \
    systemd-cat -t jj-git-fetch -p info
  exit 0
fi

Note exit 0 — the unit exits clean when the network is down. That’s intentional. A failed unit shows up as failed in systemctl --user status jj-git-fetch, and I don’t want to stare at red text every morning just because the service fired before the VPN connected. If GitHub is unreachable, the right move is to quietly skip and try again in fifteen minutes.

The check is specifically for GitHub and not a generic connectivity check (pinging 8.8.8.8, say) because fetching is only useful if the remote is reachable. A working LAN connection with no internet access would pass a generic ping and then immediately fail on every jj git fetch. This way the check and the work are testing the same thing.

Per-Repo Logging and Failure Tracking

failed=0
while IFS= read -r repo; do
  echo "jj-git-fetch: fetching $repo" | systemd-cat -t jj-git-fetch -p info
  if ! jj --repository "$repo" git fetch --all-remotes 2>&1 | \
      systemd-cat -t jj-git-fetch -p info; then
    echo "jj-git-fetch: FAILED for $repo" | systemd-cat -t jj-git-fetch -p err
    failed=1
  fi
done <<< "$repos"

exit "$failed"

Each repo gets its own log lines at info priority. Failures are logged at err priority, which means they show up more prominently in journalctl --user -t jj-git-fetch -p err. The script continues through all repos regardless — a failure in one shouldn’t skip the others. If any repo failed, the overall exit code is nonzero, which marks the systemd unit as failed and makes the problem visible in systemctl --user status. Yes, this could get noisy. No, I don’t care right now.

journalctl --user -t jj-git-fetch gives a clean view of just this service’s output, timestamped, across all runs.

The 1Password Detour

In an earlier attempt, I tried a pre-check: op whoami, to see if 1Password was authenticated before attempting fetches. The idea was that I use SSH keys managed by 1Password’s SSH agent, so if 1Password wasn’t running, the fetch would fail anyway.

Not quite the right approach though: op whoami returns nonzero unless the 1Password desktop app is fully signed in, but even when executing jj git fetch manually was fine, the check failed. I just got rid of the check and figured if a fetch fails because auth isn’t available, it can log the failure per-repo and move on. That’s a better signal than a blanket early exit based on a condition that wasn’t actually relevant.

It Works

$ systemctl --user list-timers --all
NEXT                         LEFT     LAST                         PASSED    UNIT
Mon 2026-05-19 20:02:47 EDT  13min    Mon 2026-05-19 19:47:47 EDT  1min ago  jj-git-fetch.timer
$ journalctl --user -t jj-git-fetch
May 19 19:47:47 wendigo jj-git-fetch[12053]: jj-git-fetch: fetching /home/grue/dev/blog
May 19 19:47:49 wendigo jj-git-fetch[12053]: jj-git-fetch: fetching /home/grue/dev/nix-config
May 19 19:47:52 wendigo jj-git-fetch[12053]: jj-git-fetch: fetching /home/grue/dev/wherehouse

Repos fetched, no intervention required, logs readable via journalctl. The timer fires, does its thing, and disappears. Next run in thirteen minutes.

Last updated on