Skip to content
crufiPublic

About

Tools for tracking classic Mac files in git (resource forks, CR line endings)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mac-forks

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:

  1. make pull to cleanly pull text, auto-DeRez'ed resource files, and auto-binhex'ed anything else from a Mac disk image
  2. Edit pulled vintage source files on modern macOS in VS Code
  3. Track and push vintage source and .r files with git
  4. make to instantly create a disk image (.img and .hda formats, for Mini vMac and Snow, respectively) with Rez'ed and/or un-binhexed versions of local forked files
  5. make run to 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++)
  6. Create a versioned release of vintage source in disk-image form with make release, ready to publish

What it does

  • 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 add one and you commit nothing. mac-forks encodes each fork-bearing file to a plain-text sidecar (.hqx or 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. make builds an HFS disk image containing the project, ready to mount in an emulator; make run attaches it and launches Snow directly. Edit inside the emulator's IDE, then make pull brings 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 release packages 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.

Requirements

  • macOS with the Xcode Command Line Tools (xcode-select --install) — provides binhex, 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.

Quick start: create a git repo from a vintage disk image

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.

How it works

Resource forks

Two scripts, driven automatically by file contents:

  • export.sh scans 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 with DeRez to <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 encode to <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.sh maintains a generated block in .gitignore automatically) and never tracked directly.

  • import.sh does the reverse: finds .hqx/.r sidecars 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

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.)

macroman : the encoding half alone, for .r sidecars

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

The emulator workflow

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 change
  • make / make all — builds build/<project>.img (plain HFS) and build/<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 .hda into 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), in dist/.

The overwrite guard

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 .hda newer than the .img it 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.

Folders open in list view (set-folder-views.py)

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 0200

The pieces, individually

Each 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.)

Setting up a new project

mac-forks is vendored via git subtree at the fixed path tools/mac-forks/ (the scripts assume that path).

1. Init the repo

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.

2. Create the GitHub repo, pull in mac-forks

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.sh

install.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.

3. (If bootstrapping from a disk image) pull the project from it

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.

4. Edit .gitattributes

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.

⚠️ Naming collision: mac-forks generates .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.

5. Normal .gitignore stuff

export.sh maintains its own generated block automatically — leave that alone. You'll still want the usual:

.DS_Store

6. Add your files, commit

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"

7. Verify setup with a fresh clone

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 copy

8. Push

git push -u origin main

9. (Optional) Build and launch in an emulator

Step 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.mk

Then make run. See The emulator workflow.

10. (Optional) Cutting a release

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 "..."

Keeping mac-forks up to date

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 main

Known limitations and notes

  • DeRez/Rez don'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 expect cmp to agree.
  • The .r path 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.sh once. Until then the repo is still valid; fork-bearing files just stay as their .hqx/.r sidecars, unexpanded.
  • mactext assumes 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 iconv error 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?).

License

MIT

About

Tools for tracking classic Mac files in git (resource forks, CR line endings)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages