Skip to content

Commit ac570a8

Browse files
committed
chore(scripts): add rewrite-zoxide-history utility
Rewrites zoxide's db.zo to remap user home prefixes (e.g. mfyuu/lemtoc to t1190078) after the macOS short name change, with rank merging and optional pruning of missing dirs.
1 parent 5c26ef6 commit ac570a8

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

scripts/rewrite-zoxide-history.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
"""Rewrite zoxide's history database in place.
3+
4+
Use cases:
5+
- Your macOS short name changed (e.g. mfyuu -> t1190078) and zoxide
6+
still has the old /Users/<old>/... paths cached.
7+
- You want to consolidate duplicate entries that point at the same
8+
logical directory under different prefixes.
9+
10+
What it does:
11+
1. Reads ~/Library/Application Support/zoxide/db.zo (or $_ZO_DATA_DIR)
12+
2. Rewrites every path that starts with any --from prefix to use the
13+
--to prefix instead.
14+
3. Merges entries that collide after rewrite (max rank, latest ts).
15+
4. Optionally drops entries whose target directory no longer exists.
16+
5. Writes a timestamped backup, then atomically replaces the db.
17+
18+
The db.zo binary format (version 3) is:
19+
u32 LE version
20+
u64 LE count
21+
count * {
22+
u64 LE path_len
23+
bytes path_len utf-8
24+
f64 LE rank
25+
u64 LE last_accessed (unix seconds)
26+
}
27+
"""
28+
from __future__ import annotations
29+
30+
import argparse
31+
import os
32+
import shutil
33+
import struct
34+
import sys
35+
import time
36+
from pathlib import Path
37+
38+
SUPPORTED_VERSION = 3
39+
40+
41+
def db_path() -> Path:
42+
env = os.environ.get("_ZO_DATA_DIR")
43+
if env:
44+
return Path(env) / "db.zo"
45+
if sys.platform == "darwin":
46+
return Path.home() / "Library/Application Support/zoxide/db.zo"
47+
xdg = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")
48+
return Path(xdg) / "zoxide/db.zo"
49+
50+
51+
def parse(buf: bytes) -> tuple[int, list[tuple[str, float, int]]]:
52+
pos = 0
53+
version = struct.unpack_from("<I", buf, pos)[0]
54+
pos += 4
55+
if version != SUPPORTED_VERSION:
56+
print(
57+
f"warn: db version {version} is not the tested version "
58+
f"({SUPPORTED_VERSION}); proceed at your own risk",
59+
file=sys.stderr,
60+
)
61+
count = struct.unpack_from("<Q", buf, pos)[0]
62+
pos += 8
63+
rows: list[tuple[str, float, int]] = []
64+
for _ in range(count):
65+
n = struct.unpack_from("<Q", buf, pos)[0]
66+
pos += 8
67+
path = buf[pos:pos + n].decode("utf-8")
68+
pos += n
69+
rank = struct.unpack_from("<d", buf, pos)[0]
70+
pos += 8
71+
ts = struct.unpack_from("<Q", buf, pos)[0]
72+
pos += 8
73+
rows.append((path, rank, ts))
74+
if pos != len(buf):
75+
print(f"warn: {len(buf) - pos} trailing byte(s) ignored", file=sys.stderr)
76+
return version, rows
77+
78+
79+
def serialize(version: int, rows: list[tuple[str, float, int]]) -> bytes:
80+
out = bytearray()
81+
out += struct.pack("<I", version)
82+
out += struct.pack("<Q", len(rows))
83+
for path, rank, ts in rows:
84+
path_bytes = path.encode("utf-8")
85+
out += struct.pack("<Q", len(path_bytes))
86+
out += path_bytes
87+
out += struct.pack("<d", rank)
88+
out += struct.pack("<Q", ts)
89+
return bytes(out)
90+
91+
92+
def remap(path: str, mappings: list[tuple[str, str]]) -> str:
93+
for old, new in mappings:
94+
if path.startswith(old):
95+
return new + path[len(old):]
96+
return path
97+
98+
99+
def merge(
100+
rows: list[tuple[str, float, int]],
101+
mappings: list[tuple[str, str]],
102+
strategy: str,
103+
) -> dict[str, tuple[float, int]]:
104+
merged: dict[str, tuple[float, int]] = {}
105+
for path, rank, ts in rows:
106+
new_path = remap(path, mappings)
107+
if new_path in merged:
108+
old_rank, old_ts = merged[new_path]
109+
if strategy == "max":
110+
merged[new_path] = (max(old_rank, rank), max(old_ts, ts))
111+
elif strategy == "sum":
112+
merged[new_path] = (old_rank + rank, max(old_ts, ts))
113+
else:
114+
raise ValueError(f"unknown strategy: {strategy}")
115+
else:
116+
merged[new_path] = (rank, ts)
117+
return merged
118+
119+
120+
def parse_mappings(items: list[str]) -> list[tuple[str, str]]:
121+
pairs: list[tuple[str, str]] = []
122+
for item in items:
123+
if "=" not in item:
124+
raise SystemExit(f"--map expects OLD=NEW (got {item!r})")
125+
old, _, new = item.partition("=")
126+
if not old or not new:
127+
raise SystemExit(f"--map expects non-empty OLD and NEW (got {item!r})")
128+
pairs.append((old, new))
129+
return pairs
130+
131+
132+
def main() -> int:
133+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
134+
parser.add_argument(
135+
"--map",
136+
action="append",
137+
required=True,
138+
metavar="OLD=NEW",
139+
help="prefix replacement; repeatable. e.g. /Users/mfyuu/=/Users/t1190078/",
140+
)
141+
parser.add_argument(
142+
"--db",
143+
type=Path,
144+
default=db_path(),
145+
help="path to db.zo (default: platform-specific zoxide data dir)",
146+
)
147+
parser.add_argument(
148+
"--strategy",
149+
choices=("max", "sum"),
150+
default="max",
151+
help="how to merge ranks when paths collide after remap (default: max)",
152+
)
153+
parser.add_argument(
154+
"--drop-missing",
155+
action="store_true",
156+
help="drop entries whose target directory does not exist on disk",
157+
)
158+
parser.add_argument(
159+
"--dry-run",
160+
action="store_true",
161+
help="print summary without writing anything",
162+
)
163+
parser.add_argument(
164+
"--no-backup",
165+
action="store_true",
166+
help="skip writing db.zo.bak-<timestamp> next to the db",
167+
)
168+
args = parser.parse_args()
169+
170+
mappings = parse_mappings(args.map)
171+
if not args.db.exists():
172+
print(f"error: db not found: {args.db}", file=sys.stderr)
173+
return 1
174+
175+
buf = args.db.read_bytes()
176+
version, rows = parse(buf)
177+
print(f"db: {args.db}", file=sys.stderr)
178+
print(f"version={version} entries={len(rows)}", file=sys.stderr)
179+
180+
merged = merge(rows, mappings, args.strategy)
181+
dup_dropped = len(rows) - len(merged)
182+
183+
items = list(merged.items())
184+
if args.drop_missing:
185+
kept = [(p, r, t) for p, (r, t) in items if os.path.isdir(p)]
186+
else:
187+
kept = [(p, r, t) for p, (r, t) in items]
188+
missing_dropped = len(items) - len(kept)
189+
190+
print(f"after remap+dedup ({args.strategy}): {len(merged)} (-{dup_dropped} duplicates)", file=sys.stderr)
191+
if args.drop_missing:
192+
print(f"after exists filter: {len(kept)} (-{missing_dropped} missing dirs)", file=sys.stderr)
193+
194+
if args.dry_run:
195+
print("(dry-run: not writing)", file=sys.stderr)
196+
return 0
197+
198+
if not args.no_backup:
199+
backup = args.db.with_name(f"{args.db.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
200+
shutil.copy2(args.db, backup)
201+
print(f"backup: {backup}", file=sys.stderr)
202+
203+
tmp = args.db.with_suffix(args.db.suffix + ".tmp")
204+
tmp.write_bytes(serialize(version, kept))
205+
os.replace(tmp, args.db)
206+
print(f"wrote {args.db} ({len(kept)} entries)", file=sys.stderr)
207+
return 0
208+
209+
210+
if __name__ == "__main__":
211+
sys.exit(main())

0 commit comments

Comments
 (0)