Git in the box — one checkout, two sides¶
Your repo lives on your computer, and the VM mounts it, so the box and your computer work on the
same files and the same .git. A commit made on one side is on the other at once. That's the
point, but it means two operating systems are writing one git repository over a shared mount, and
git was never designed for that. This page covers what foldyard does about it, what that changes
for you, and the few things not to do. Terms in bold are in the glossary.
The box's git is a wrapper¶
Git keeps what you've staged in one file, .git/index, and it rewrites that file far more often
than you'd think: a plain git status refreshes it, so an editor or git app that just shows the
repo on your computer is writing it too. Git's way of replacing the file safely only holds within
one machine. Over the mount, two writers racing each other could leave it empty, and git status
then showed every file in the repo staged as deleted.
So git in the box is a small wrapper, the git index shim, that foldyard installs when it
creates the box. It works the same in any box image. It gives box-side git its own index file
(.git/index-box; each worktree gets its own too). Commits, branches and objects are still
shared, so nothing about sharing work changes. Only staging is now kept per side, and the two
sides never write the same index file.
That split has one catch, and the shim handles it. When one side commits or switches branch, the other side's staging still describes the old commit. Committing it as it stands would quietly undo the other side's work. So:
- Before each command in the box, the shim brings the box's index up to date with a move your computer made. Anything you'd staged in the box is carried along when it doesn't touch the files that changed.
- After a box commit, the box brings your computer's index up to date as well, under git's own
lock, so your next
git statusthere is right. - When it can't do that safely (staged work on both sides touching the same files), it says so
and refuses
git commituntil you sort it out, as below.
Over the mount, the box can also briefly see an older version of a branch your computer just moved: measured at up to about a second on macOS with Lima, and up to five seconds on the podman backend. The shim checks every branch move under git's own lock, so a box command can't move a branch back over a commit you just made.
The design and every case it covers are in ADR-0021
(fy docs adr-0021). To turn the shim off, set [box] git_index_split = false
(configuration); that brings the race back.
When git refuses: "foldyard git shim: …"¶
Every refusal starts with foldyard git shim:, and refuses before anything is changed (most say
so: nothing was changed). Read the middle:
- "your computer moved it while this ran", "HEAD kept moving", "reads as having no commits, but it has history", "another git holds its lock" — the box caught a stale view. Run the same command again.
- "your computer is in the middle of a git operation (rebase-merge)" (or a merge, cherry-pick, revert) — wait until it's finished on your computer, then run it again. Don't abort that rebase from the box.
- "your computer has a leftover autostash" — a
git pull --rebaseon your computer stopped before it began and left the changes it had set aside in its rebase directory. It won't clear by itself andgit rebase --abortcan't clear it. On your computer,fy doctorshows aleftover autostashrow with the exact commands: keep the changes as a stash, remove the directory, then apply the stash. - "not moving … back from … to …" — a command asked to move the branch to an older commit
without saying what it expected to replace. If you meant that, use
git reset --soft <commit>orgit reset <commit>instead; if you didn't, your computer just moved the branch, so run it again. - "index-box is stale … can't be carried forward", then "refusing 'git commit' on the stale index" — recover in the order below.
Don't route around the wrapper (calling another git binary, FY_GIT_SHIM_OFF=1): those refusals
are what keeps a commit in the box from silently reverting one on your computer.
When git status looks mass-wrong¶
Changes staged that nobody made, or whole folders staged as deleted, mean an index is stale, not
that your work is gone. Never git reset --hard: it overwrites your files with the last
commit and throws away uncommitted work.
A plain git reset (mixed) is almost right, but check git status --short first. A line
starting AD is a file that was staged and then removed from disk: its content now exists only
in the index, and a reset drops it. So write those files out first, then reset:
git checkout-index -a # write out files that exist only in the index (leaves your files alone)
git reset # unstage everything — mixed, never --hard
git add … # stage again what you meant to commit
This is what the shim prints when it refuses. The same order works on your computer. There,
nothing guards git, so if the box has committed since you last staged, glance at git status
before you commit.
The stash is shared¶
There is one git stash list for your computer, the box, every worktree and every agent. A bare
git stash pop takes whatever was stashed last, which may be someone else's. Label yours and
pop it by number:
git stash push -m my-label
git stash list # find stash@{n} for my-label
git stash pop 'stash@{n}'
Don't git stash -u or git clean -d while the stack is up¶
When a compose service bind-mounts a directory inside another bind mount (say ./app is
mounted at /srv, and ./lib at /srv/lib), the engine creates the inner mount point as an
empty directory in your checkout if it doesn't exist. Git ignores empty directories, so it shows
as nothing at all — but git stash -u and git clean -d delete untracked directories, and that
one goes with them. The containers keep running ("Up") while the service inside can no longer
find its files.
To compare against a clean tree, use a worktree, or a plain git stash (no -u). If it already
happened, recreate the directory (mkdir -p <path>) and restart the containers that mount it.
The executable bit¶
The two sides of the mount don't always agree on a file's permission bits, so a script can look
modified just because its executable bit flipped. foldyard sets core.fileMode = false in the
main checkout's .git/config the first time it sets up the stack for that checkout (fy up,
fy ps or any stack command, and foldyard shellenv), and only writes it when it isn't already
set. Worktrees share that setting.
With it, git ignores executable-bit changes on disk, so chmod +x alone won't show up in a
commit. To commit a file as executable, tell git directly:
git update-index --chmod=+x path/to/script.sh
Pushing¶
git push from the box is refused, by design: no SSH key, token or credential helper reaches it.
fy verify checks this with a dry-run push to origin. Commit in the box; push from your
computer, where your credentials live.
If the box needs to work on GitHub itself (open a pull request, reply to review comments), give it
a GitHub App switch — see github-app.md. That token is used for GitHub's API,
not for git push, and it can do exactly what the App's installation allows: an App with
contents: write can change the repo through the API, so fy config widenings shows what each
App was last read to grant.
Git hooks and aliases are a separate matter: the box can write .git/hooks, lefthook.yml or
.git/config, and they run on your computer the next time you use git there
(security).
Worktrees¶
For parallel work, make a worktree on your computer with fy worktree add <name>: its own
checkout and branch (wt/<name> by default, forked from the trunk), its own box, stack, mode and
ports, all in the one VM. Don't run git worktree add in the box, and fy worktree add refuses
there: it would record paths in the shared .git that exist on only one side.
Every box sees every checkout: main's box reaches the worktrees, a worktree's box reaches main
and the other worktrees. Edit and read across them freely, but run installs in a checkout from
its own box. Each box keeps only its own checkout's [box].shadow_volumes dirs in volumes; the
same dirs of every other checkout are read-only in it, so pnpm install or uv sync there fails
instead of writing onto your computer's disk. The note in each of those dirs,
RUN-IT-IN-ITS-OWN-BOX, gives the command: from that directory, WORKTREE=<name> fy box exec
'<command>' runs it in the worktree's own box (WORKTREE= fy box exec '<command>' in main's), as
long as that box is running — WORKTREE=<name> fy box up on your computer starts it. A worktree
added after a box was created isn't masked in that box until it's recreated: fy worktree add
names the boxes it isn't masked in, and fy doctor's box shadowing row reports it.
fy worktree remove <name> takes down that worktree's box, stack and volumes, deletes the
checkout and keeps its branch. A worktree with uncommitted work needs --force; that work is
saved first on a ref in the main checkout (refs/fy/removed/<name>-<time>), and the command
prints how to restore it.
What fy doctor checks¶
git shim(in the box) — thegiton PATH is foldyard's shim. If it isn't, the image put another git first on PATH or the install failed, and none of the above protects you. A new box runs this check once at the end offy box up.git shim hooks(in the box) — the hooks the shim uses to check branch moves are installed, and only root can change them. The fix for either row:fy box down && fy box upon your computer.shared git config(both sides) —.git/configstill has git's own keys and a remote. A config file emptied by a write race fails here, with the keys to restore; no remote is a warning, sincefy verifythen can't prove the push refusal.leftover autostash (<checkout>)(your computer) — the stuckgit pull --rebaseabove, with its recovery commands.