Develop classic Mac OS software with modern git workflow and period toolchains.
mac-forks makes
resource-fork-bearing files (THINK C/Symantec C++/CodeWarrior/MPW
projects, ResEdit resource files) and MacRoman, CR-terminated source text
first-class citizens of an ordinary git repo on today's macOS, and can get
the whole project on and off emulator disk images with one make command.
After setup (see Quick start, below) you can:
make pullto cleanly pull text, auto-DeRez'ed resource files, and auto-binhex'ed anything else from a Mac disk image- Edit pulled vintage source files on modern macOS in VS Code
- Track and push vintage source and
.rfiles withgit maketo instantly create a disk image (.imgand.hdaformats, for Mini vMac and Snow, respectively) with Rez'ed and/or un-binhexed versions of local forked filesmake runto automatically open the disk image in Snow (creating it first if it's missing), in Finder list view and ready to build and run in your vintage toolchain (e.g., Symantec C++)- Create a versioned release of vintage source in disk-image form with
make release, ready to publish
- Preserves resource forks. Git only sees a file's data fork. A Symantec
project file (
.π) or any resource file (.rsrc) keeps everything that matters in its resource fork —git addone and you commit nothing. mac-forks encodes each fork-bearing file to a plain-text sidecar (.hqxor DeRez'd.r) that git can store and diff, and reconstitutes the real file on every checkout, automatically, via git hooks. - Cleans MacRoman text files for git. Classic Mac source is MacRoman encoded with CR-only line endings: GitHub shows the whole file as one line, and every character with the high bit set (curly quotes, ©, é…) displays wrong. In mac-forks, a pair of git filters stores clean UTF-8/LF blobs — readable, diffable, reviewable — while the working tree keeps genuine Mac Roman/CR files a vintage toolchain expects. (Finder type/creator codes survive the round trip via a magic comment added atop each file.)
- Smoothly gets code in and out of an emulator.
makebuilds an HFS disk image containing the project, ready to mount in an emulator;make runattaches it and launches Snow directly. Edit inside the emulator's IDE, thenmake pullbrings the changes back into your working tree as ordinary git modifications. (A safety guard refuses to overwrite a disk image that holds edits you haven't pulled.) - Bootstraps git projects from disk images. One command pulls a new repo straight from a folder on an existing HFS disk image — every file copied fork-intact.
- Makes releases.
make releasepackages the source disk image into a versioned zip, ready to attach to a GitHub Release.
Everything is driven by the file(s) you're working with; the same
tools/mac-forks/ directory can be dropped into any project unchanged.
- macOS with the Xcode Command Line Tools (
xcode-select --install) — providesbinhex,DeRez,Rez,SetFile. - For the emulator/disk-image tools only:
hfsutils(brew install hfsutils) and, for Snow's SCSI-style images, djjr.
Each script checks for the tools it specifically needs and says if anything's missing.
A common starting point for me: a project already lives in a folder on
an HFS disk image (emulator hard disk image, BlueSCSI card image, floppy
dump). pull-from-disk.sh copies
everything from the image correctly and fork-intact.
(In contrast, hfsutils hcopy's text mode
rewrites line endings and remaps Mac Roman through Latin-1,
corrupting every non-ASCII character.)
mkdir my-project && cd my-project
git init
git commit --allow-empty -m "Initial commit" # subtree add below needs a commit to attach to
git remote add mac-forks https://github.com/crufi/mac-forks
git subtree add --prefix=tools/mac-forks mac-forks main --squash
sh tools/mac-forks/install.sh # hooks, filters, starter .gitattributes/Makefile
# copy the project off the disk image, forks intact:
sh tools/mac-forks/pull-from-disk.sh /path/to/disk-image.hda "HFS:Path:To:Project ƒ"
git add .
git commit -m "Add project source"You now have a working repo: fork-bearing files are tracked as .hqx/.r
sidecars (the real files regenerate on every clone/checkout), text files
are stored as clean UTF-8/LF and materialize as Mac Roman/CR. Continue
with the full checklist below for
.gitattributes tuning, GitHub setup, and emulator interaction.
Starting from files already sitting on your modern Mac instead? Same
sequence, minus the pull-from-disk.sh line.
Two scripts, driven automatically by file contents:
-
export.shscans the working tree for files with a non-empty resource fork and encodes each one to a plain-text sidecar:- a
*.rsrc-named files (case-insensitive) with an empty data fork is decompiled withDeRezto<name>.r. DeRez only knows about the resources themselves — not the file's own Finder type/creator — so that's captured as a human-readable leading comment, e.g./* mac-forks: type=rsrc creator=RSED */. - anything else with a resource fork is archived with
binhex encodeto<name>.hqx. (BinHex is preferred over the more space-efficient MacBinary because it's 7-bit-clean text, which keeps GitHub's file view usable.)
The real, fork-bearing files are gitignored (
export.shmaintains a generated block in.gitignoreautomatically) and never tracked directly. - a
-
import.shdoes the reverse: finds.hqx/.rsidecars and reconstitutes the real files, restoring resource fork and Finder type/creator exactly.
Both are wired up as git hooks by install.sh:
| Hook | Runs | Why |
|---|---|---|
pre-commit |
export.sh |
keeps tracked sidecars in sync with the real files before every commit |
post-checkout |
import.sh |
rebuilds real files after checkout/clone/switch |
post-merge |
import.sh |
rebuilds real files after merge/pull (a fetch+merge doesn't go through checkout) |
Because the real files are gitignored rather than tracked, import.sh
writes them directly — there's no tracked path for git's own checkout
machinery to race against.
Classic Mac text has two properties that break modern tools:
- Lines end in a bare CR (0x0D). Git's line-ending machinery
(
core.autocrlf,text=auto,eol=) only understands LF and CRLF, so it can't help — a diff, or GitHub's blob view, renders the file as one giant line. - The encoding is Mac Roman, not UTF-8. Anything above 7-bit ASCII is a different character when read as UTF-8 or Latin-1.
The mactext filter pair fixes both: clean converts Mac Roman → UTF-8 and CR → LF, so
the stored blob is ordinary, correctly-rendering, diffable text; smudge
reverses both, so the working copy is a genuine Mac Roman, CR-only file.
mactext-clean also captures the file's type/creator and Finder information as a leading comment:
/* auto-generated (do not modify): type=TEXT creator=KAHL hex=544558544B41484C... */
These files only carry Finder info when they came off a real HFS volume
(most modern editors don't preserve xattrs) — so the value captured at
first check-in is carried forward on later commits, and import.sh restores it to the working file on checkout. (A
smudge filter can't do that restore itself: its stdout is the file
content git writes, so it can't safely side-effect the same path.)
You declare which extensions get the filter in your project's
.gitattributes:
*.c filter=mactext -text
*.h filter=mactext -text
Editing these working-tree files with a modern editor is fine, with one
condition: the editor must read and write them as Mac Roman. VS Code
supports that ("Western (Mac Roman)") but defaults to UTF-8, under which
every non-ASCII byte renders — and saves — as unrecoverable tofu �.
install.sh drops a .vscode/settings.json into new projects that sets
the encoding per-language for C/C++ files. (Line endings need no such treatment:
editors save LF/CRLF, and the pipeline normalizes
any break style back to true CR at commit and at disk-image build.)
DeRez output embeds raw resource-fork bytes in its hex-dump comments, and
for text-bearing resources (STR#, vers, …) those bytes are genuine
Mac Roman prose that renders wrong on GitHub. But DeRez emits LF line
endings — there's no CR convention to preserve, and adding CR↔LF
conversion would corrupt the sidecar. So .r sidecars get a different
filter, macroman, which is just mactext minus the line-ending step.
*.r filter=macroman -text
With snow.mk included in your Makefile (see the
checklist), the day-to-day loop is:
make run # build the disk image, attach it, launch Snow
# ... edit/build inside the emulator's IDE ...
make pull # bring in-emulator edits back into the working tree
git diff # review them like any other local changemake/make all— buildsbuild/<project>.img(plain HFS) andbuild/<project>.hda(SCSI-style device image for Snow), every folder pre-set to open in list view "by Name" (see below).make run— builds as above, then attaches the image to a copy of your Snow workspace, launches Snow.make pull— copies edits back off the existing.hdainto the working tree (deliberately without rebuilding first, which would destroy the very edits it's rescuing).make clean— removes the build directory and generated workspace.make release— versioned zip of the disk image(s), indist/.
Rebuilding or make clean-ing the .hda destroys whatever's on it —
which is a problem if it holds in-emulator edits you haven't pulled.
guard-overwrite.sh gates every destructive step with two checks:
- Whole-image: is the
.hdanewer than the.imgit was converted from (beyond normal build sequencing)? - Per-file: does any file's HFS catalog date postdate that build? This also catches files that exist on the disk with no tracked counterpart — created inside the emulator, never pulled — and lists them separately, in green.
If anything trips, the guard prints exactly what it found and demands a
typed confirmation before proceeding, else aborts with the .hda
untouched. make pull first if you want to keep the changes. Set
FORCE=1 (e.g. make run FORCE=1) to skip the check for
non-interactive use.
A freshly-minted HFS volume opens every window in "by Icon" view, which is
charming for about five seconds and then you want a list (at least, I do). This called for automation! The Finder
keeps each folder's view in the folder's own catalog record — the
frView field of its DInfo (see IM: Toolbox Essentials, Finder
Interface), not in the Desktop file/database, which
only caches bundle/icon lookups. So the build just sets it at the
source: after build-floppy.sh finishes, set-folder-views.py walks
the catalog B-tree and stamps every directory record, root window
included, so everything comes up in a tidy "by Name" list with staggered windows.
Implementation note: I had to learn the bytes to write with a tiny bit of reverse engineering (set a folder to "by Name" in emulated 7.5.5, shut down cleanly, read the bytes back off the volume). And it takes more
than frView: the Finder ignores the whole record until kIsInited set in frFlags and there's a non-empty saved
window rect (an empty frRect apparently reads as "this window never existed", and
the Finder starts fresh, by Icon, every time). The
patch pokes a few bytes inside each record on the plain .img before the djjr conversion, so the .hda
inherits it for free. If your Finder speaks a different dialect (or
you're a "by Kind" person), calibrate the same way and
pass the value yourself:
tools/mac-forks/set-folder-views.py build/my-project.img 0200Each tool also works standalone, outside the Makefile:
build-floppy.sh out.img [blocks] [label] [text_creator] builds a
plain HFS image (no partition map, i.e. not an hda) containing every file mac-forks tracks
for the project. Text files are copied raw (hcopy
would remap Mac Roman through Latin-1 and corrupt non-ASCII characters)
then stamped TEXT plus your toolchain's creator code (e.g. KAHL, so
sources double-click-open in THINK C). Fork-bearing files are bridged
through MacBinary, since hcopy cannot read a macOS file's resource
fork directly. Works for any emulator that mounts HFS images.
pull-from-disk.sh <disk-image> [hfs-start-folder] is the reverse:
copies files off a disk image (plain .img or SCSI-style .hda) into
the working tree, raw and fork-intact. Tracked files update in place; new
files (created on the disk image, so-far untracked) are rescued recursively too,
with hidden Finder folders (Trash, Desktop Folder, …) skipped.
hfs-start-folder (e.g. "Dev:My Project ƒ" — HFS paths are
colon-separated) scopes everything to that folder and drops its name from
the resulting local paths; this is the bootstrap-a-repo-from-a-disk mode
shown in the Quick start.
A nonexistent folder fails with an error. Finishes by running export.sh, so
sidecars immediately reflect what came back and the next make knows to
rebuild.
snow-attach-disk.py <template.snoww> <disk.hda> <output.snoww>
copies a Snow workspace, adding the disk image in the first empty SCSI
slot. Only that one entry is touched: the template's other entries (ROM,
PRAM, existing disks) are typically bare relative filenames that Snow
resolves relative to the workspace file's location, so the output
must land in the same directory as the template — snow.mk writes it to
SNOW_PATH for exactly that reason. The .hda must be a SCSI-style
device image; snow.mk produces one from the plain HFS image with
djjr convert to-device.
release.mk — make release VERSION=v1.2.0 zips the source disk
image into dist/ (plus the .hda, when snow.mk is also included).
VERSION defaults to git describe. This packages source, not a
compiled binary — the actual build happens by hand inside the vintage
IDE. (Tagging is deliberately left to plain git: declaring a release
mutates shared repo state, which a make target shouldn't do as a side
effect.)
mac-forks is vendored via git subtree at the fixed path
tools/mac-forks/ (the scripts assume that path).
git init
git commit --allow-empty -m "Initial commit"The empty commit is needed by the later git subtree add (next step). If you already have
project files in the directory, committing those works just as well.
gh repo create my-project --public --source=. --remote=origin # or --private
git remote add mac-forks https://github.com/crufi/mac-forks
gh repo set-default origin # handy since the project has two remotes, counting mac-forks
git subtree add --prefix=tools/mac-forks mac-forks main --squash
sh tools/mac-forks/install.shinstall.sh checks for required tools, symlinks the hooks, configures
the mactext/macroman filters, and creates a starter
.gitattributes, Makefile, and .vscode/settings.json from
tools/mac-forks/templates/ if you don't already have them (it never
overwrites existing files). Every
clone needs to run it once — hooks and filter config live in .git/,
which git clone doesn't populate.
sh tools/mac-forks/pull-from-disk.sh /path/to/disk.hda "Folder:Project ƒ"See the Quick start
for why this, not manual hfsutils hcopy, is the way to get files off an
HFS volume intact.
Step 2 created a starter .gitattributes. Now we tune the extension list for your project:
*.r gets macroman (mac-forks' own generated sidecars, always
LF-native); your actual vintage source gets mactext:
*.hqx -text
*.r filter=macroman -text
*.c filter=mactext -text
*.h filter=mactext -text
*.cp filter=mactext -text
*.cpp filter=mactext -text
*.hpp filter=mactext -text
Add more filter=mactext -text lines for whatever else your project
has — .p/.pas (Pascal), .a/.asm, etc.
.r sidecars for
resource-only files (Foo.rsrc → Foo.rsrc.r). If your project has
genuine hand-written Rez source files ending in .r, rename them (.rez or
similar) before using mac-forks.
export.sh maintains its own generated block automatically — leave that
alone. You'll still want the usual:
.DS_Store
Edit everything normally — including the real .π/.rsrc files directly
in ResEdit or the IDE. The pre-commit hook encodes anything with a
resource fork automatically; you don't need to git add those files yourself.
git add .
git commit -m "Add project source"Problems in this kind of setup mostly only show up on a fresh clone (an already-configured working copy hides plenty), so make one:
cd /tmp && git clone /path/to/your/repo verify-me && cd verify-me
sh tools/mac-forks/install.sh
# diff verify-me's files against your real working copygit push -u origin mainStep 2 created a starter Makefile; now point it at your setup:
SNOW_WORKSPACE ?= $(HOME)/Snow/your-workspace.snoww # your Snow workspace
TEXT_CREATOR := KAHL # or whatever your vintage toolchain expects
VOLUME_LABEL := My Project # HFS volume name shown in the emulator
include tools/mac-forks/snow.mk
include tools/mac-forks/release.mkThen make run. See The emulator workflow.
git tag -a v1.0.0 -m "First public release"
git push origin v1.0.0
make release VERSION=v1.0.0
gh release create v1.0.0 dist/*-v1.0.0.zip \
--title "v1.0.0" --notes "..."Pulling in later mac-forks improvements:
git subtree pull --prefix=tools/mac-forks mac-forks main --squash -m "Pull mac-forks updates"Pushing a change you made in-place back upstream:
git subtree push --prefix=tools/mac-forks mac-forks mainDeRez/Rezdon't round-trip byte-for-byte — Rez recompiles a semantically equivalent resource fork, not necessarily identical bytes. Fine for ResEdit/an IDE/a linker; don't expectcmpto agree.- The
.rpath only ever produces an empty data fork on import (that's the only case it's used for). A file with both real data-fork content and a resource fork goes through the BinHex path, which is fully fork-agnostic. - Hooks are local to each clone — every clone runs
install.shonce. Until then the repo is still valid; fork-bearing files just stay as their.hqx/.rsidecars, unexpanded. mactextassumes the only control characters in the text are line breaks.- Mac Roman can't represent all of Unicode: if a modern editor introduces
a character outside its repertoire (an emoji, say), the working-tree
conversion on the next checkout fails with an explicit
iconverror rather than guessing. - Filenames containing consecutive spaces aren't matched reliably by the disk-image tooling (single spaces are fine, and what are you doing with double-spaced filenames anyway?).