Skip to content

feat(git): git-backed stacks, core and command line (#138, part 1) - #171

Merged
mstrhakr merged 20 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-core
Oct 8, 2026
Merged

mstrhakr merged 20 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-core

Conversation

@grantemsley

Copy link
Copy Markdown

First of four PRs for git-backed stacks (#138): the core and a command-line tool, compose-git. Next come
credentials for private repositories, then the web UI, then scheduled checks. A git stack is an indirect stack
pointing at a plugin-managed clone, so the Compose page shows and runs it like any other stack.

Why so many checks: this runs as root on a box that often holds the only copy of people's appdata, so the
rule is that no typo or odd repository state loses data. Nothing is written unless the array is started and
the path is on a live mount, nothing of the user's is deleted (local changes are saved as a patch first,
untracked and ignored files are never touched), and no container changes until every check, pull and build has
passed. Each check is there for a failure we hit or reproduced.

What it adds

  • compose.sh -c gitdeploy: fetch, check out, check, pull, build, then up, under the stack's usual lock. A
    failure before up puts the previous commit back; a failed up is recorded, not rolled back.
  • Pre-flight checks built on docker compose config (no YAML parsing of our own): invalid compose, unset
    variables, .env.example keys missing from the .env, missing networks, volumes, bind sources, files and
    build folders, and container names or ports already taken.
  • Recreate the stack when its folder changed (on by default, per stack), so a commit that only edits a mounted
    config file takes effect. With it off, such a commit deploys but the container silently keeps the old file.
    Flipping the default is one line if you prefer opt-in.
  • compose-git add | convert | check | deploy | reclone | status, with the exit status as the result, and
    --help per command.
  • docs/git-stacks.md, including moving an existing stack into git.

Ordinary stacks are unchanged: every StackInfo edit is behind isGitStack(), and compose.sh only gains the
gitdeploy case and its entries in the lock, credential-log and exit-status lists.

Reading it: the 14 commits are meant to be read in order, each one step with its tests: path guard, git
runner, settings, clone and checkout, saving local changes, StackInfo, pre-flight checks, deployed-commit
record, gitdeploy, recreate on folder change, compose-git, docs, missing networks (#167), override prune
(#169).

Testing: 303 new PHPUnit tests (real git against throwaway repositories) and 21 Bats tests; the full suite
and PHPStan pass at every commit. On Unraid 7.3.2 with real Docker and Compose, end-to-end scripts covered
deploys from the array and from GitHub, each kind of refusal, failed and interrupted deploys, local changes and
container data in the clone, reclone, convert, the #167 and #169 paths, and the web UI's Stop/Start on a git
stack.

Notes

  • git and docker run through proc_open with an argument list and a fixed environment, not exec(): no shell,
    stdout and stderr kept apart, and a stray GIT_DIR cannot redirect git.
  • PHP 8.1 features (readonly, array_is_list); Unraid 6.12 ships 8.2.
  • compose-git is at scripts/compose-git; a symlink from the .plg would put it on the PATH. I left the
    .plg alone.
  • GitCommand::run() takes extra environment variables no caller here uses yet; the credentials PR needs them.

grantemsley and others added 15 commits October 6, 2026 14:34
…he flash

On Unraid, /mnt itself is RAM. A path that is not on a mounted disk, pool
or share (a typo such as /mnt/usr/appdata, or /mnt/user while the array is
stopped) silently lands in RAM, and a stray file left in /mnt/user can stop
the user shares from mounting at the next array start.

GitPathGuard checks, immediately before any write, that the array is
started, that the path is on a live mount below /mnt, and that no symlink
leads out of it. createDirectory() creates missing levels one at a time and
never creates a mount point or a share.

- A tmpfs, ramfs or rootfs mount under /mnt does not count as a disk:
  Unassigned Devices can put a tmpfs over /mnt/disks, and a folder left
  there for an unplugged disk must not pass.
- An exclusive share (Unraid 6.12+), where /mnt/user/<share> is a symlink
  to /mnt/<pool>/<share>, is followed to the pool when the pool is mounted.
  Any other symlink at the share level is refused.

Nothing uses it yet; the git stack code in the following commits does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
Every git call for git-backed stacks goes through GitCommand::run(). It
ignores the system and global git config, never prompts, runs no repository
hooks, allows only the https and local-path transports, starts from an
empty environment so stray GIT_DIR or GIT_WORK_TREE values cannot redirect
it, passes arguments without a shell, and is stopped by GNU timeout after a
time limit.

git refuses a repository owned by another user ("dubious ownership"). A
clone can end up owned by nobody:users after Unraid's New Permissions tool,
and a repository on this server is often owned by the container that keeps
it. Each run trusts the folder it works in and any repository the caller
names, and nothing else, through a global config file that holds only those
safe.directory entries (git 2.47 ignores safe.directory given with -c when
it reads from a local repository).

ProcessRunner reports a command killed by a signal as 128 plus the signal
number, and counts it as timed out only when the time limit was reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
A git stack keeps its repository address, branch, compose file path and
clone folder in git.json in the stack folder. Every value is checked when
the settings are created and again on every load, since the file can be
edited by hand or restored from a backup:

- the address must be an https URL with no user name or password in it,
  or a repository path under /mnt;
- the branch must pass git check-ref-format and cannot start with "-";
- the compose path must be a relative .yml/.yaml path that stays inside
  the repository and outside .git;
- the clone folder must be under /mnt and carry the stack's clone id, so
  two stacks can never share one.

A file with missing, extra or wrongly typed settings is reported, never
partly used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
GitClone manages the clone behind one git stack. It clones only into a
folder that does not exist yet (and removes only that folder if the clone
fails), marks the clone with the stack's id, and before changing anything
checks that the folder is still that marked clone of that repository.

The clone is checked out detached at a commit and never committed to.
Untracked files, such as data a container writes through a relative bind
mount, are never removed or overwritten: there is no git clean and no forced
checkout, and a checkout that would put a file where an untracked one is
stops and changes nothing. A plain checkout refuses that by itself, so the
whole repository is checked out (a sparse checkout overwrites untracked
files silently). Ignored files need one more flag: git treats them as
expendable and overwrites them by default, so every checkout passes
--no-overwrite-ignore. A data folder listed in .gitignore is the usual
layout, so this case matters most.

Local edits to tracked files are listed, and a checkout is refused while
there are any. Saving and discarding them is the next commit.

A repository on this server is trusted by git even when another user owns
it, so a stack can deploy from one a container keeps under appdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
…ing them

This is the only code that discards the user's own changes, so it is a step
of its own.

Local changes in the clone (edits, and commits made by hand) can be saved
as a patch in the stack folder, and are discarded only after that. The
patch is the only copy once they are discarded, so a short write (a full
flash) stops the discard.

Discarding is refused, before anything is saved, when an untracked or
ignored file sits at a path tracked at the checked-out commit (a tracked
file replaced by a folder of data, or a file removed from the index and
then edited): "git reset --hard" would delete or overwrite it, and the
patch would not hold it. Staged changes are unstaged first, because a
staged new file (for example after "git add -A" swept up a container's
./data folder) is not listed as untracked, and the reset would delete it.

isOnBranch() and lastCommitSharedWithBranch() tell a commit made by hand
in the clone apart from a newer commit of the branch, so the patch can be
taken against the right base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
A git stack is an indirect stack whose target is a plugin-managed clone, so
anything the plugin writes into that folder would show up as a local change
and could be lost to the next pull.

For stacks with git.json, StackInfo now:
- reads, creates and saves the plugin-managed override in the stack folder;
- applies both overrides when composing: the repository's own (if it has
  one), then the managed one, so a repository override no longer hides the
  plugin's labels. An override file that does not exist is left out;
- uses the stack folder's .env if there is one, so secrets can stay out of
  the repository, and otherwise a .env committed next to the compose file,
  for people who keep their settings versioned with the stack. The editor
  creates a new .env in the stack folder;
- never uses default compose file discovery, so its files are always named.

Ordinary stacks behave exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
GitDeployCheck runs `docker compose config` with the stack's real files,
env file and profiles, and works on the JSON it prints; the plugin never
parses YAML itself. A deploy is stopped, naming each problem, when:

- compose reports an error, the file defines no services, or a variable
  used by the compose file is not set (a blank value can create state that outlives fixing it);
- the repository's .env.example lists keys missing from the .env in use
  (key names only, never values);
- an external network or volume does not exist;
- a bind-mount source is under /mnt but not on a mounted disk, pool or
  share (Docker would create it in RAM), its share does not exist (a typo,
  or it would quietly become a new share), or it is outside /mnt and does
  not exist. Missing folders below an existing share, such as a new stack's
  appdata/newapp/config, are left for Docker to create and named in the
  deploy log. Sources inside the clone (relative paths such as ./data) are
  allowed;
- a config or secret file, or a local build folder, does not exist;
- a container name or published port is already taken by another stack or
  a plain container.

Docker is run through DockerCommand (no shell, time limit). The tests use a
scripted stand-in for the docker CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
GitStackState is git_state.json in the stack folder: the last commit that
deployed successfully, and the commit whose deploy failed, if the last one
did. It is written only when a deploy finishes, so the flash sees one small
write per deploy. A broken file is reported, never guessed at, and the
format follows the same change rules as git.json.

The next commit, gitdeploy, reads and writes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
`compose.sh -c gitdeploy` deploys a git stack under the stack's usual lock:

1. git_stack.php prepare fetches the branch (or takes a given commit),
   refuses if the clone has local changes (or saves them as a patch and
   discards them when asked), refuses if the compose file no longer exists
   at that commit, checks it out, and runs every pre-flight check. If any
   of that fails, the previous commit is put back and no container has
   been touched.
2. The images are pulled, then services with a build section are built,
   as steps of their own. If either fails, the previous commit is put back.
3. `up -d --remove-orphans` starts the stack from the new commit.
   On success the commit is recorded as deployed in git_state.json and
   superseded images are removed, as Update and auto-update do. On failure
   the commit is recorded as failed. A half-finished up is not rolled
   back, and the next deploy starts from the failed commit.

Every answer from the git helper is checked: PHP notices go to stderr, the
previous commit must be a full commit id, and a failure to read the compose
files stops the deploy. If putting the previous commit back fails, the
message says the clone is still at the new commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
Plain "up -d" recreates a container only when its compose definition
changed, so a commit that only edits a bind-mounted config file was
checked out but never took effect. A new per-stack setting in git.json,
recreateOnFolderChange (on by default), makes the deploy add
--force-recreate when anything in the stack's folder changed since the
previous commit. Recreate, not restart: a restart keeps a single-file
mount on the old copy of the file.

If the commits cannot be compared, the previous commit is put back and no
container is changed, the same as a failed pull or build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
compose-git creates, deploys and looks after git stacks from the command
line (scripts/compose-git):

- add: clone a repository and make a new git stack from it;
- convert: turn an existing stack into a git stack. Its folder, name,
  project name, .env and plugin override stay; the old compose file and
  indirect settings are moved into a dated backup folder, and a .env
  that sat next to an indirect stack's old compose file is copied in;
- check: compare the deployed commit with the branch, changing nothing;
- deploy: run compose.sh gitdeploy, with the stack's credential;
- reclone: move the clone aside (never delete it: untracked data lives
  there) and clone again at the deployed commit, moving the old one back
  if the new clone fails;
- status [--json]: what is deployed, failed and changed locally.

Stacks are named by their exact folder, convert and reclone take
compose.sh's lock for the stack, and the exit status is the result (0 ok,
1 failed, 2 usage, 3 check found a newer commit) so scripts and git hooks
can use it. Cloning now also refuses, and removes the new clone, when the
compose file is not on the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
How git stacks work, a quick start, and a step-by-step guide to moving an
existing stack into git (including getting its files into a repository
and keeping secrets out). Also: how to lay out repositories (every stack
keeps its own clone, so keep a shared repository small and give big
stacks their own), which .env is used, making folders writable for
non-root containers, recreating on config changes, local changes and
patches, failed deploys, the pre-flight checks, and the compose-git
command reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
…setting is on

With Create Missing External Networks on, compose.sh creates a stack's
missing `external: true` networks before up and update. A git deploy did
not: its pre-flight check refused the stack over the missing network, so
the same stack started by hand but would not deploy from git.

With the setting on, the pre-flight check no longer counts a missing
external network as a problem (external volumes are still checked), and
gitdeploy creates the missing networks right before up. That is after
every check, pull and build has passed, so a deploy that stops earlier
creates nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL
… checks

The web UI prunes the plugin-managed override before every up; a git
deploy did not. When a commit renames or removes a service, the override
keeps an entry for it with labels only, and compose refuses the whole
stack over a file the user did not write. Prune it after the checkout and
before the checks, and log each service removed.

This relies on mstrhakr#169: before it, pruneOrphanOverrideServices() never
removed anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMScBowJk7yyfEwraTdgg
compose.sh kept its lock files in a fixed /var/run/compose.manager, which
only root can create. The GitHub runner is not root, so every gitdeploy
test failed at the lock with "Another operation is already in progress".
The local test container runs as root, which hid it.

The lock folder can now be set with COMPOSE_LOCK_DIR, the same way
COMPOSE_LOCK_TIMEOUT already sets the wait. The default is unchanged. The
gitdeploy tests point it at their temporary folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMScBowJk7yyfEwraTdgg
@Nikosan73

Copy link
Copy Markdown

Sorry, I opened this can and disappeared.

Thanks, @grantemsley this is a thorough design. I'd want those safety checks on a server that holds real data. The command-line tool with exit codes matters most to me because our releases run via scripts and agents over SSH.

Our priorities differ a little, so a few points from my side.

Releases are deliberate. We only release when a person approves a specific version. For us, the schedule would stay on "notify" and never "deploy". Could new git stacks default to off or notify, so nothing deploys unattended unless someone chooses it?

We release versions, not branch heads. Each release is a numbered version. I see deploy --commit already covers a specific commit. Could it also accept a tag, for example compose-git deploy myapp --tag v1.4.0? Then we could deploy the exact version we approved without looking up the commit id.

Some releases need a step before the new containers start, such as a database migration. I would suggest the docs show the pattern of a one-off migration service that the app waits for (depends_on with condition: service_completed_successfully). That keeps it inside compose and needs no new code. Does that sit well with your pre-flight checks?

None of this should hold up part 1. Happy to see it land as planned.

@grantemsley

Copy link
Copy Markdown
Author

@Nikosan73 No problem, I'm just happy that I'll soon be able to stop manually deploying stuff on unraid.

To address your concerns:

Unattended deploys are off by default. Right now there's no automatic updates, but the PR that includes scheduling will give each stack the option for off (default), notify, and deploy. My intention is to pick the safest defaults so nothing surprises anyone in an update.

Deploying a tag can't be done today. The clone is a single branch with no tags fetched, and it currently only takes a full commit id to prevent a typo deploying the wrong thing. Adding a --tag option to deploy that looks up the tag and resolves the commit for you could maybe be added later. Or if you deploy via scripts or CI, you can have that look up the commit id (git rev-parse v1.4.0^{commit}), as long as the tag is on the stack's branch.

Migrations as a one off service fit right in with the Folders and files your containers need section for other prep work your stack needs. The preflight checks don't have any issue with that, so I can just include an example in the docs later. I tested it with --wait: a migration that exits non-zero fails the deploy and records the commit as failed, but compose has already recreated the app containers, so the stack is down until the next deploy.

@Nikosan73

Copy link
Copy Markdown

Thanks, that all works for us. Off by default is what I hoped for, and git rev-parse covers tags until a --tag option exists.

One follow-up on migrations. If a failed migration leaves the stack down until the next deploy, that is the outcome we most want to avoid. Could there be an optional pre-deploy command per stack? It would run after fetch, checkout, pull and build, but before up. For example, docker compose run --rm migrate against the new commit.

A non-zero exit would then count like any other pre-flight failure. The previous commit goes back, and the running containers are never touched. That fits the rollback you already have before up. It cannot undo a migration that half-applied, but the old app stays up while someone looks.

Again, not something to hold part 1 for.

@mstrhakr

mstrhakr commented Oct 7, 2026

Copy link
Copy Markdown
Owner

I'll be merging this today after the dev->main PR is merged so that this is the main feature of the next beta. That gives us time to get it all put together before we push it to main.

This comment was marked as resolved.

grantemsley and others added 5 commits October 7, 2026 09:48
…s are taken

The container-name and port checks took any failed docker command as "nothing
is taken": a daemon that is not answering, or a timeout, let the deploy go on
to "up", which would then find the clash after changing some containers.

Now only "No such container" means the name is free. Any other failure, and
output that is not JSON, is reported as a problem and stops the deploy before
"up", like every other check. A container removed between "docker ps" and the
bulk inspect is still fine: inspect prints the others and the removed one holds
no port.

The fake docker used by the tests can now be told to fail a command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NavH8cj9aU49Qs1pQPa1Qc
The port check compared the stack's published ports only with running
containers of other stacks. Two services of the stack itself asking for the
same host port passed it, and "up" then started one service and failed on the
other, after the containers had changed.

The ports are now also compared with each other, with the same address and
protocol rules as against other containers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NavH8cj9aU49Qs1pQPa1Qc
…itten

reclone cleared the deployed commit when the rewritten branch no longer held
it. The next deploy then had nothing to compare the new commit with, so it
ran a plain "up", and a container whose bind-mounted config changed in the
rewrite kept the old file.

The deployed commit now stays recorded. The next deploy finds it gone from the
clone and recreates every container, which GitDeploy::upArguments already
does for a commit that cannot be measured against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NavH8cj9aU49Qs1pQPa1Qc
The profiles, services and build caches of a git stack were compared with the
repository's override only while that file existed. A commit that removed the
override, and changed nothing else, left the caches looking fresh, so the
plugin kept answering from a file that was no longer a compose input.

When the override is missing, its folder's modification time is compared
instead: removing a file updates the folder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NavH8cj9aU49Qs1pQPa1Qc
A container with a bind mount into the clone keeps the moved folder until it
is recreated, and a rewritten branch leaves the new clone at its latest
commit. Say both next to the reclone command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NavH8cj9aU49Qs1pQPa1Qc
@grantemsley

Copy link
Copy Markdown
Author

@Copilot Thanks, all six are right and each is fixed as its own commit on top:

  • Missing containers vs. inspect errors, fail closed on docker ps, reject failed or
    malformed bulk inspect
    : 0efe816. Only "No such container" now means a name is free. Any other
    failure of docker ps or docker inspect, and output that is not JSON, is reported as a problem and
    stops the deploy before up, like every other check. A container removed between ps and the bulk
    inspect is still accepted, since inspect prints the others and the removed one holds no port.
  • Duplicate requested port bindings: 9cfcaf9. The stack's own published ports are now compared with
    each other too, with the same address and protocol rules.
  • Retain the deployed SHA after a branch rewrite: 80ed389. reclone keeps it, so the next deploy
    takes the existing "commit no longer in the clone" path and recreates every container. Checked on
    the test box: rewrite, prune, reclone, deploy, and the containers were recreated.
  • Caches when the repository override is deleted: a26b48a. When the override is missing, its
    folder's mtime is compared instead, which a removal updates.

ae4aee4 is a docs line next to reclone, since a bind mount keeps following the moved-aside folder
until the container is recreated.

Tests cover each case, and the suite is green.

@Nikosan73 A predeploy step could fit in pretty well. The one thing options built into the compose can't handle is the service failing, and a predeploy script could abort the deploy if needed. That would leave it up to your script to decide when to continue, when to roll back, and when to stop and wait for manual intervention. I'll add that to the list of things to look into after the features I've already got in progress are done.

@mstrhakr
mstrhakr merged commit 5108db5 into mstrhakr:dev Oct 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants