Commit a1842e7f6d71

Vincent Demeester <vincent@sbr.pm>
2026-02-25 16:14:10
refactor: rewrite lazyworktree-raffi in Python
Replaced fragile bash pipeline (find|sed|jq|jq) with pure stdlib Python. Fixed race condition where concurrent raffi invocations could read a truncated cache file, causing EOF JSON parse errors. Cache writes are now atomic via tempfile and os.replace.
1 parent 6a2b738
Changed files (2)
pkgs/my/scripts/bin/lazyworktree-raffi
@@ -1,51 +1,94 @@
-#!/usr/bin/env bash
-# List git repositories under ~/src/ for raffi script filter.
-# Outputs Alfred Script Filter JSON format.
-# Results are cached for 2 hours.
-# Usage: lazyworktree-raffi [query]
-set -euo pipefail
+#!/usr/bin/env python3
+"""List git repositories under ~/src/ for raffi script filter.
 
-CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/lazyworktree-raffi"
-CACHE_FILE="$CACHE_DIR/repos.json"
-CACHE_MAX_AGE=7200 # 2 hours in seconds
-SRC_DIR="$HOME/src"
+Outputs Alfred Script Filter JSON format.
+Results are cached for 2 hours.
+Usage: lazyworktree-raffi [query]
+"""
 
-mkdir -p "$CACHE_DIR"
+import json
+import os
+import sys
+import tempfile
+import time
 
-# Rebuild cache if missing or older than CACHE_MAX_AGE
-rebuild=0
-if [[ ! -f "$CACHE_FILE" ]]; then
-    rebuild=1
-elif [[ "$(uname)" == "Darwin" ]]; then
-    file_age=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE") ))
-    [[ "$file_age" -gt "$CACHE_MAX_AGE" ]] && rebuild=1
-else
-    file_age=$(( $(date +%s) - $(stat -c %Y "$CACHE_FILE") ))
-    [[ "$file_age" -gt "$CACHE_MAX_AGE" ]] && rebuild=1
-fi
+CACHE_MAX_AGE = 7200  # 2 hours
+SRC_DIR = os.path.expanduser("~/src")
 
-if [[ "$rebuild" -eq 1 ]]; then
-    find "$SRC_DIR" -name .git -type d -not -path '*/worktrees/*' 2>/dev/null \
-        | sed 's|/\.git$||' \
-        | sort \
-        | jq -R --arg src "$SRC_DIR" '{
-            title: (. | ltrimstr($src + "/") ),
-            subtitle: .,
-            arg: .
-        }' \
-        | jq -s '.' \
-        > "$CACHE_FILE"
-fi
 
-query="${*:-}"
+def cache_paths():
+    cache_home = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
+    cache_dir = os.path.join(cache_home, "lazyworktree-raffi")
+    os.makedirs(cache_dir, exist_ok=True)
+    return cache_dir, os.path.join(cache_dir, "repos.json")
 
-if [[ -z "$query" ]]; then
-    jq '{ items: . }' "$CACHE_FILE"
-else
-    jq --arg q "$query" '
-      [ .[] | select(
-          .title | ascii_downcase | contains($q | ascii_downcase)
-      )]
-      | { items: . }
-    ' "$CACHE_FILE"
-fi
+
+def needs_rebuild(cache_file):
+    try:
+        age = time.time() - os.path.getmtime(cache_file)
+        return age > CACHE_MAX_AGE
+    except FileNotFoundError:
+        return True
+
+
+def find_repos():
+    repos = []
+    for root, dirs, _files in os.walk(SRC_DIR):
+        # Skip worktree internal directories
+        if "worktrees" in root.split(os.sep):
+            dirs.clear()
+            continue
+        if ".git" in dirs or ".git" in _files:
+            repos.append(root)
+            dirs.clear()  # Don't descend into nested repos
+    repos.sort()
+    return [
+        {
+            "title": os.path.relpath(r, SRC_DIR),
+            "subtitle": r,
+            "arg": r,
+        }
+        for r in repos
+    ]
+
+
+def rebuild_cache(cache_dir, cache_file):
+    repos = find_repos()
+    fd, tmpfile = tempfile.mkstemp(suffix=".json", dir=cache_dir)
+    try:
+        with os.fdopen(fd, "w") as f:
+            json.dump(repos, f)
+        os.replace(tmpfile, cache_file)
+    except BaseException:
+        os.unlink(tmpfile)
+        raise
+
+
+def load_cache(cache_file):
+    try:
+        with open(cache_file) as f:
+            data = f.read()
+            if not data:
+                return []
+            return json.loads(data)
+    except (FileNotFoundError, json.JSONDecodeError):
+        return []
+
+
+def main():
+    cache_dir, cache_file = cache_paths()
+
+    if needs_rebuild(cache_file):
+        rebuild_cache(cache_dir, cache_file)
+
+    items = load_cache(cache_file)
+
+    query = " ".join(sys.argv[1:]).strip().lower()
+    if query:
+        items = [i for i in items if query in i["title"].lower()]
+
+    json.dump({"items": items}, sys.stdout)
+
+
+if __name__ == "__main__":
+    main()
pkgs/my/scripts/default.nix
@@ -5,7 +5,7 @@
 
 stdenv.mkDerivation {
   pname = "vde-scripts";
-  version = "0.8";
+  version = "0.9";
 
   src = ./.;