
6 min read
Don't Keep Your Git Repos in iCloud
Duplicate posts, a git index that wiped itself, and a pull that died with mmap failed. Three weird bugs, one cause: my repo lived in iCloud Drive.
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);Digital Duct Tape @ Plantonix
I run operations for an Amazon and Shopify based business. Sometimes I blog about web development or tech stuff, like building this site and the tools I find useful along the way. I spend my off-hours in Claude Code and a new garden my girlfriend and I just started.


A soil mix calculator for garden beds. Enter your bed size and get the right amounts of coco coir, perlite, vermiculite, and worm castings, with preset and custom ratios.

A tool that allocates quart sized units from bulk totes, with live sliders and real time math. Built to speed up bulk product planning at my day job.

Automations for iPhone and Mac built with the Shortcuts app: utilities, toolkits, and experiments published on RoutineHub.

· 6 min read
Duplicate posts, a git index that wiped itself, and a pull that died with mmap failed. Three weird bugs, one cause: my repo lived in iCloud Drive.

· 2 min read
Before deleting my entire site for the rebuild, I made a one-command backup that keeps the old code browsable forever. Here's the five-minute git trick.

· 5 min read
I tore this entire site down to the studs and rebuilt it with Astro 7, with an AI doing the heavy lifting. Here's the story, and the start of a series.

· 3 min read
I recently started co-hosting a podcast for RoutineHub, check out Episode 5!

· 3 min read
Use macOS Shortcuts to control any app that has menus, buttons, or accepts keyboard input.