Skip to content

Git Based Stacks and Bug Fixes - #172

Open
mstrhakr wants to merge 116 commits into
mainfrom
dev
Open

mstrhakr wants to merge 116 commits into
mainfrom
dev

Conversation

@mstrhakr

@mstrhakr mstrhakr commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Summary of Changes

This PR introduces core Git Stacks functionality to compose_plugin, enabling safer and more robust repository-backed deployments directly through Docker Compose.

Key Features

  • Git Stacks Core (compose-git): Added the new compose-git command alongside a locked-down Git runner to safely clone, fetch, and check out repository stacks.
  • State & Settings Management: Implemented validation, configuration persistence, and tracking of deployed commits for git stacks.
  • Resilient Deployments:
    • Checks stack state before any deploy touches containers.
    • Creates missing external networks automatically and prunes orphan override services prior to deployment.
    • Handles local changes by saving them as patches before any discards, keeping .env and managed overrides out of clones.
    • Recreates containers automatically when the underlying stack folder changes.
  • Safety Guards & Bug Fixes:
    • Added memory and resource safeguards to prevent git stacks from writing directly to RAM.
    • Fixed port collision detection for stack services and stopped deploys if Docker returns ambiguous names/ports.
    • Maintained deployed commits correctly when a reclone hits a rewritten branch or a checkout removes a repository override.
  • Testing & Documentation: Included non-root Bats tests mirroring CI environments and added a complete Git Stacks Guide.

Fixes #170
Fixes #165
Fixes #179
Closes #138

Copilot AI balanced review requested due to automatic review settings October 8, 2026 13:51
grantemsley and others added 23 commits October 8, 2026 09:55
…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 #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
…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
Align documented requirements with the plugin's PHP 8 dependency. Remove obsolete patch scripts, assets, installer hooks, settings and action handling, while retaining the pre-7.0 Ace fallback and Git patch features.

Add installer compatibility regression coverage for the minimum version and Ace fallback. Fixes #170.
@mstrhakr mstrhakr changed the title Git Based Stacks Git Based Stacks and Bug Fixes Oct 8, 2026

This comment was marked as resolved.

Since #149 made --build opt-in, updating a stack whose image: is only built locally failed on pull access denied. Retry the pull with --ignore-buildable and add --build to up when the plain pull fails.
grantemsley and others added 25 commits October 12, 2026 15:05
…hanges

Check for Changes read what was deployed before asking the remote, and
took the stack's lock only for the fetch. A deploy or rollback that
finished while the remote was being asked was not counted: the check
could report the old commit as deployed and offer a redeploy that was
no longer needed.

The remote is still asked before the lock is taken, so a slow remote
does not hold up a deploy. The deployed commit is then read under the
lock, and the answer is built from that. A check is now refused while
another operation on the stack runs, also when nothing needs fetching,
because that operation may be about to change the deployed commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two read-only boxes that show a stack's deploy key had no label, so
a screen reader announced an unlabelled text box. The Sources tab's box
is now labelled by the help text above it, and the box in the Add Stack
and Move into git panels gets an aria-label. The other fields these
dialogs add already have a label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitClone::isCommitId() accepts a 40-character SHA-1 id and a
64-character SHA-256 id, but the messages that ask for a commit id said
it has to be 40 characters. That was wrong for a SHA-256 repository.
They now name both lengths: the Deploy a commit prompt and its error,
the web request's error, and compose-git deploy's --commit help.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every ssh stack got a deploy key of its own. A repository that holds
several stacks, one folder each, then needed one key per stack in its
settings, each with exactly the same read-only access. GitHub refuses
one key on two repositories, but not one key for several stacks of the
same repository.

A new ssh stack now uses, in this order:
- the key of another ssh stack of the same repository (the same user,
  host, port and path), which the repository already knows, so no key
  needs adding at all;
- a key made for the repository earlier, as by a first attempt whose
  clone failed because the key had not been added yet;
- a key named after the stack by an older version of the plugin, as
  before, but only while no git stack uses it: with keys shared, a
  stack of another repository may still use it, and a key in use
  cannot be deleted to make way;
- a new key, now named after the repository ("deploy key for
  git@github.com:you/stacks.git"), so later stacks of the repository
  find it.

The other stacks' git.json files are read as plain JSON, as the
Credentials tab reads them, so a stack whose settings fail a check
still counts; one that cannot be read at all is named in a message.

Keys made before keep their names and their stacks. The Credentials
tab already lists every stack that uses a key, and refuses to delete
one in use. The help, the docs, the Add Stack and Move into git
dialogs, the Sources tab and the delete message say a key belongs to
the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacks added before a repository's stacks shared one deploy key each
have a key of their own, and an ssh stack's key could not be changed.
compose-git credential <stack> <name> now takes, for an ssh stack, a
deploy key of the same repository: one that another stack of the
repository uses, or the one made for the repository. The repository is
reached with that key first, and nothing is saved unless that works.
The old key stays on the Credentials tab, and the command says to
delete it there and from the repository once no stack uses it.

Naming the key the stack already uses changes nothing, and says so.
Anything else is still refused for an ssh stack, now naming the keys it
could be given: no key at all, an HTTPS token, another repository's
key, or a key no stack of the repository uses. The move is on the
command line only, as changing a credential already was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(git): git stacks in the web UI (#138, part 3)
…edule

git.json gets an optional autoDeploy key: what the scheduled check (next
commits) does when a new commit changes the stack. "off" does nothing and
is the default, so it is left out of the file and a stack written before
this still loads; "notify" sends a notification; "deploy" deploys it.

compose-git auto-deploy <stack> off|notify|deploy sets it, and status
shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed deploy pauses a git stack: the scheduled check (next commits)
leaves it alone until a deploy works. compose-git resume <stack> clears
the failure for someone who fixed the stack another way, by hand or with
the web UI's Update, keeping the deployed commit as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web UI's deploy turns the stack's wait-for-healthy settings into
compose.sh's --wait arguments inline; that moves into
gitDeployWaitArguments() in Helpers.php, so the scheduled deploy (next
commit) waits the same way. compose-git deploy's --wait arguments end in
the same function, so the three cannot drift. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…do not throw

withStackLock() throws when another operation holds the stack's lock.
tryStackLock() is the part that takes the lock, returning null instead,
for a caller that treats a busy stack as "try again later" rather than
as an error: the scheduled check in the next commit. withStackLock()
now uses it; nothing changes for its callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose-git scheduled-check makes one pass over the git stacks whose
auto-deploy is not off (the next commit runs it from cron):

- It asks the remote for the branch's latest commit, once per repository,
  branch and credential in a pass, and fetches only when that is new.
- "Behind" means the new commit changes the stack's own folder in the
  repository. In a repository shared by several stacks, a commit to
  another stack's folder changes nothing here. The comparison is with the
  deployed commit or, after a failed deploy, with the commit that failed:
  that is what the containers were last started from.
- Each new commit that changes a stack is handled once: "notify" sends a
  notification; "deploy" runs compose.sh gitdeploy through
  compose_background.sh, which logs to the stack's last_cmd.log and
  notifies whether it worked.
- A commit is not deployed, and one notification says why and what to do,
  when the stack's last deploy failed, its clone has local changes or a
  commit made by hand, or it is not running (stopped, or never deployed:
  as the image auto-update does, a commit never starts a stopped stack).
  Once that is cleared (or a notify stack is switched to deploy) the
  waiting commit is handled again. A stack whose project name is not
  settled is skipped, as the image auto-update skips it.
- A scheduled deploy that fails before up (an image pull, say) is
  recorded as failed and not tried again: compose-git status shows it,
  and compose-git resume now clears it as well as a failed up, so the
  next pass tries again.
- A stack another operation holds is tried again on the next pass, also
  when nothing needs fetching: what the stack runs from is read under its
  lock, so a deploy or rollback that is running is never acted on half
  done. An unreachable repository does not stop the other stacks being
  checked.

What a pass learned is kept in git_check.json in the stack folder, written
only when it changes, so a pass with nothing new writes nothing to the
flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k asks for it

The plugin's cron file gets a third line, marked #compose-gitcheck, that
runs compose-git scheduled-check every 15 minutes. It is there while any
git stack's auto-deploy is not off, and every rebuild of the file (image
auto-update on or off, the backup schedule, compose-git auto-deploy)
works that out again, so turning image auto-update off never removes it.

The check runs on its own, not inside AutoUpdateRunner: that runner stops
when no image auto-update is configured, and holds its own lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck is behind

- The Sources tab of a git stack gets "Automatic deploys": Off, notify me
  when a new commit changes the stack, or deploy it. It is saved at once,
  through the same code as compose-git auto-deploy, cron line included.
- After a failed deploy, the stack menu offers "Resume Automatic
  Deploys", as compose-git resume does.
- The stack list shows "behind" next to a git stack's branch and commit
  when the scheduled check last saw a newer commit that changes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The git stacks guide gets an "Automatic deploys" section: the three
choices, what "changes the stack" means in a shared repository, why a
commit is held back (a failed deploy, local changes, a stack that is not
running) and how it goes on, and the cron line. The web UI table and the
command reference gain auto-deploy, resume and scheduled-check, and "No
automatic deploys yet" leaves the limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checks a git deploy runs before up now return the problems they
found, and prepare() still turns them into the error that stops the
deploy. Nothing changes for a deploy. The next commit, a dry run of a
deploy, lists the problems instead of stopping on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain git status refreshes the index and writes it back, taking
.git/index.lock while it does. The Sources tab and the scheduled check
ask for a clone's local changes without the stack's lock, so a checkout
in the clone at that moment (a deploy's, or putting the previous commit
back after a failed check) could fail on "index.lock: File exists" and
leave the clone at the wrong commit.

git status --no-optional-locks reads without writing the index, which is
what git documents for background status readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP keeps what realpath() and file_exists() found for a while. After a
checkout replaced the compose file with a symlink, a process that had
already resolved the file's path got the old answer, and the check
that the compose file stays inside the clone passed.

A deploy runs its checks in a fresh process (git_stack.php), so it was
not affected. The dry run in the next commits resolves the compose file
before and after the checkout in one process, and its test found this.
Every checkout now clears PHP's file cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dry run runs every check a deploy runs before it pulls the images,
and stops there: fetch, check out the commit, run the checks, then
check the commit from before out again. It also compares the compose
configuration of the two commits, and says whether up would recreate
every container. No container is started, stopped or recreated, nothing
is pulled or built, and the stack's state is not changed. Like a
deploy, it checks the commit out in the clone itself, so for as long as
the checks run, a container reading a folder of the clone sees the
tried commit's files.

The configuration is compared as "docker compose config
--no-interpolate --no-env-resolution" prints it: variables as written,
not their values, so nothing from the .env or the env files is printed.
When it cannot be read or compared, the dry run says why and still
reports the checks.

The checks prune the plugin's override as a deploy's do; the dry run
puts the override back as it was, unless it was saved again meanwhile
(from the Labels tab, say). Local changes in the clone stop the dry run,
as they stop a deploy, and are kept. The dry run takes the stack's lock
and is refused while another operation on the stack runs, and refused
for a stack whose project name is not settled, as a deploy is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the dry run from the previous commit and prints what it found:
which commit would be deployed (and the last one deployed successfully,
when the clone holds another after a failed up), whether every check
passed or what would stop the deploy, whether up would recreate every
container, and how the compose configuration changes. The exit status
is 0 when every check passed and 1 when one did not, so a script can
try a commit before deploying it. A dry run does not pull images, so it
says that a missing image or tag is found only by the deploy.

--dry-run takes only --commit and --profile: the options for saving
local changes and waiting for healthy containers mean nothing without a
deploy, and are refused rather than ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduled check's lock, git-scheduled-check.lock, sits in the folder
where each stack's lock is <project name>.lock. A stack named
git-scheduled-check therefore had the pass's own lock as its stack lock:
the pass already held it, so that stack always looked busy and was never
checked.

The pass's lock is now .git-scheduled-check.lock. A project name never
starts with a dot, so no stack's lock can be that file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduled check compares one commit, the branch's head when it asked,
and records that commit as handled. But it started the deploy without a
commit, so the deploy fetched again and took whatever the branch's head
was by then. If the branch moved from B to C in between, C was deployed
while B was recorded: a failure of C before up was reported as B's, and
the next pass could try C again despite the once-per-commit rule.

The pass now passes the commit it compared to the deploy (compose.sh
--git-commit). A newer commit is compared and handled by the next pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ride

The dry run kept a copy of the plugin's override from before its checks,
and after them wrote that copy back if the override was still as the
checks' prune left it. A save from the Labels tab after the copy was
taken but before the prune was lost that way: the prune read the newer
save (and, when it had nothing to remove, left it as it was), so the
override still matched what the prune left, and the older copy was
written over it.

The prune now says what it read and what it wrote when it changed the
override. The dry run puts back what the prune read, and only when the
prune changed something and nothing saved the override since. A save
before the prune is what the prune read, so it is kept.

The dry run also now puts back the file the prune writes, the project
override (getProjectOverridePath()). It used to read getOverridePath(),
which on a stack with an indirect override is the indirect file, not the
one the prune changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dry run's documentation said nothing from the .env or the env files
is printed. That holds for the configuration diff, which is read with
--no-interpolate, but not for what the checks find: those are the
deploy's own messages, read from the resolved configuration, so a
missing folder or external volume whose path or name is built from a
variable is named as it resolves. A deploy's log says the same.

The docs, the --dry-run help and the code comment now say so, and keep
the promise to the diff it holds for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… applies

The scheduled check decides to deploy a commit (automatic deploys set to
Deploy, no failed deploy, no local changes, the stack running) without
the stack's lock, since the deploy it starts must take that lock itself.
Things can change before the deploy gets it: a Stop or Down queued
meanwhile ran first, and the deploy then started the stack someone had
just stopped; or the stack was switched to Off or Notify while the check
asked the remote, and it was deployed anyway. And since the deploy now
gets the commit the check compared, a Pull and Redeploy (or a rollback)
that ran in between would be replaced by that commit, which may be older.

compose.sh gitdeploy takes a new --automatic-deploy-from option, which
the scheduled check passes with the deployed commit it compared from
("none" if none). Under the stack's lock, before fetching, git_stack.php
checks all of that again, and that the deployed commit is still the one
the check compared from (GitDeploy::assertAutomaticDeployStillApplies()).
When one no longer holds, nothing is changed and gitdeploy exits 75
(GIT_DEPLOY_EXIT_SKIPPED, EX_TEMPFAIL): no failed result is saved, the
background runner logs it without a failure notification, and the
scheduled check records the commit as not handled, so its next pass
decides again with what it finds then (and says so once, as for any
stopped stack). A clone or docker that cannot be asked is a skip too, as
it is for the scheduled check itself. A failed up that happens to exit 75
exits 1 instead, so it is never read as a skip.

The docker ps question moves to GitDeploy::hasRunningContainers(), and
the local-changes question to GitDeploy::localChanges(); the scheduled
check and the deploy both use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(git): check git stacks on a schedule, deploy new commits, and try a deploy first (#138, part 4)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
foreach (glob(rtrim($this->composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) {
// Read as plain JSON: the scheduled check itself validates each stack's settings.
$settings = json_decode((string) @file_get_contents($gitSettingsFile), true);
}
foreach (glob(rtrim($composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) {
// Read as plain JSON: a git.json that fails validation still keeps its credential in use.
$gitSettings = json_decode((string) @file_get_contents($gitSettingsFile), true);
@grantemsley

Copy link
Copy Markdown

Both of Copilot's new findings (the byte order mark in CronManager.php and Exec.php) are real, and they're fixed
in #182. The same plain read was in a third place, deploy key sharing in GitStackManager.php, so that's fixed
there too. All three now go through one reader that strips the mark the same way GitStackSettings::load() does,
and each has a regression test. It also stops two other ways a credential a stack still uses could be deleted: a
git.json that isn't valid JSON, and a projects folder on a stopped array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

4 participants