Skip to content

feat(git): git stacks in the web UI (#138, part 3) - #178

Merged
mstrhakr merged 23 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-ui
Oct 13, 2026
Merged

mstrhakr merged 23 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-ui

Conversation

@grantemsley

Copy link
Copy Markdown

The third of four PRs for git-backed stacks (#138). #171 and #174 added git stacks and private repositories
behind the compose-git command; this one puts them on the Compose page. Every screen calls the same code as the
matching compose-git command, so the web UI and the command line behave the same.

What it adds

  • Add New Stack gets a fourth compose source, Git repository. Create clones the repository and goes back
    to the stack list; nothing is started. An ssh repository gets its own deploy key, and the dialog shows it when
    the repository refuses the clone.
  • The stack menu gets Check for Changes, Pull and Redeploy and Deploy Commit.... Deploys run
    compose.sh gitdeploy in the usual terminal window, asking for profiles as Update does. Check for Changes says
    whether a newer commit changes the stack's folder or only files outside it (in a shared repository, usually a
    commit to another stack). A clone with a problem (missing, unreadable) is not deployed, and the dialog says why.
  • The editor: the Sources tab shows the repository, branch, commits and deploy key in place of the Compose
    Source choice, and other stacks get Move this stack into git.... The Compose tab says the file is in the
    clone. Changes made in the clone are shown in a box on both tabs, since the next deploy stops on them.
  • The stack list shows the branch and deployed commit under the name. Delete names the clone it leaves.
image image image image image

Changes to existing behaviour

  • Saving the editor's settings no longer touches a git stack's indirect files, which could have turned it back
    into a project-folder stack.
  • performComposeAction() shows a refusal that comes back as {error, message} instead of opening it as a
    terminal URL; this also fixes echoComposeCommand()'s existing wait_conflict answer.
  • compose.sh gitdeploy runs docker in the compose file's folder with only the environment the deploy's checks
    used, whatever starts it: the web UI starts it from the web server, whose folder and environment were never
    checked.
  • compose-git check exits 4 for a newer commit that changes only files outside the stack's folder; 3 now means
    one changes the folder (or nothing is deployed yet).

Reading it: in order, each commit one step with its tests. 1-5 are small follow-ups to #174; 6 is the
gitdeploy environment; 7-15 add the Sources tab, Add Stack, a refactor with no change in behaviour, the deploy
menu items, the Compose tab banner, the stack list line, Check for Changes, Move into git and Delete; 16 is the
docs, 17 a Playwright spec, 18 the changed-in-the-clone box. The server side is one new class,
GitStackWebActions, plus echoGitDeployCommand() next to echoComposeCommand().

Testing: 44 new PHPUnit tests and 3 Bats tests; the full suite passes at every commit and PHPStan reports
nothing. The new Playwright spec (off unless mutation tests are enabled and a test repository is given) adds,
deploys, checks and deletes a git stack through the UI, and passes on Unraid 7.3.2 with the existing specs. By
hand on the same server: every state the UI shows (behind, a commit to another stack, changes in the clone, a
hand-made commit, a missing clone, a refused deploy key, a failed deploy, a lost deployed commit), converting a
running stack, and deleting a git stack.

grantemsley and others added 18 commits October 10, 2026 16:41
The Credentials tab now also holds git repository tokens and git stacks'
deploy keys, but its heading, its empty-table text and the save and
delete log lines still said "registry credential". Deleting a deploy key
logged "Deleted registry credential", which is the line someone would
search the syslog for.

Say "credential" in those four places. The save log line already names
the provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test's name said the secret goes to the callback only, but it checks
the opposite: whatever the callback returns, the secret included, goes
back to the caller, as useCredential's comment says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing a git credential asked the repository with the 60-second limit
of a check or deploy, so a host that does not answer held the browser's
request for a minute. The registry test waits at most 15 seconds; the
git test now does too.

The failure message also said "Could not reach" twice ("Could not reach
<url> (used by 'x'): Could not reach the repository: ..."). It now names
the repository and the stack once, then gives the reason.

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

An ssh stack's deploy key is found again by the stack's name and host, so
that a second attempt at adding the stack uses the key the user has just
added to the repository. A key left behind by a deleted stack of the same
name matches too, even when the new stack is for another repository, and
GitHub refuses a deploy key that another repository already has.

The vault does not record the repository, so rather than add a field to
it, the message now names the reused key and says to delete it first when
it belongs to another repository. The docs say the same next to the
deploy key steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a host gave no keys, the message asked "Is the address right?"
whatever the reason, though a DNS failure or an unreachable host on this
server looks the same to the user. It now gives ssh-keyscan's own error
when it prints one (a name that does not resolve, for one; a refused or
unreachable port prints nothing), or says that no key came back, and
asks to check both the address and that this server can reach the host.

The first test that runs ssh-keyscan checks the message against a port
nothing listens on.

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

compose-git deploy starts compose.sh with the checks' environment, in
the compose file's folder, so up deploys what the checks saw. The web
UI's deploy starts compose.sh too, from the web server's folder and
environment, so ${PWD} in a compose file (or a variable that environment
happens to carry, such as DOCKER_HOST) could still differ between the
checks and up.

compose.sh gitdeploy now does it itself, whatever starts it: it runs
docker in the compose file's folder (git_stack.php compose-folder, the
folder the checks use), with PATH, HOME, LC_ALL=C and PWD set to that
folder, plus DOCKER_CONFIG (the registry credential) and TERM when set.
That covers every docker call of the deploy: docker compose, the docker
network calls of the missing-network step (create_missing_external_networks
in common.sh takes the docker command to use) and the image cleanup.
Nothing else of compose.sh's environment reaches docker. PATH is
compose.sh's own, which is the fixed one for compose-git and the
system's for the web UI.

The Bats docker stand-in now reads its settings from a file, since the
environment no longer reaches it, and records the environment of up,
network create and rmi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a git stack the Sources tab shows the repository, branch, compose
file, clone, deployed and checked-out commits, any failed commit, files
changed in the clone, and an ssh stack's deploy key, in place of the
Compose Source choice.

Saving the editor's settings no longer touches a git stack's indirect
files: its compose file is the one in its clone, set by git.json, and a
form that sent no compose source would have turned it back into a
project-folder stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Stack gets a fourth compose source, "Git repository": the repository
address, branch, the compose file's path in the repository, and for an
https repository a git token from the Credentials tab. Creating the stack
clones the repository through the same code as compose-git add, and
deploys nothing. It then goes back to the stack list, saying the stack
was added (with an ssh stack's host key fingerprints), rather than
opening the editor: a git stack's compose file is changed in its
repository.

The dialog stays open while it clones, and shows any error in place. An
ssh repository gets a deploy key of its own. When the server refuses the
clone, which usually means it does not know the key yet, the dialog shows
the key in a panel of its own, with a Copy key button, and the clone's
error under it.

For that, GitClone::create() throws GitCloneFailedException when git
clone itself fails, keeping everything git said: only the lines before
the "fatal:" line tell a refused key from an unreachable host. The other
failures (the folder already exists, the compose file is not on the
branch) are shown as the error they are. The latter says no clone was
made. A refusal worded in a way not recognised still gets the key,
inside the error as before. GitStackManager throws
GitDeployKeyNotAddedException for the panel, so the web answer carries
the key apart from the error; compose-git prints the same message as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose-git deploy's command line for compose.sh gitdeploy moves into
buildGitDeployCommand() in Helpers.php, and its choice of profiles (the
ones asked for, else the running ones, else the defaults) into
gitDeployProfiles() beside it, so the web UI's deploy (next commit) runs
exactly the same command. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A git stack's menu gets "Pull and Redeploy" (the branch's latest commit)
and "Deploy Commit..." (a commit given by its full id, to go back to an
older version). Both run compose.sh gitdeploy in the usual terminal
window, with the same command as compose-git deploy, and wait for healthy
containers when the stack's setting says so.

Before deploying, the menu lists any files changed in the clone, which
would stop the deploy, and offers to save them as a patch in the stack
folder's git-changes folder and discard them. A problem found in the
clone without asking the remote (the clone missing, say) would stop the
deploy, so the menu shows it instead and offers no Deploy.

A compose action that is refused before it starts with {error, message}
(other than the identity chooser's) is now shown as an error, instead of
being opened as a terminal URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Compose tab of a git stack shows a banner: the file is in the
plugin's clone, so a change belongs in the repository followed by Pull
and Redeploy. An edit saved here is a local change, which the next
deploy stops on, and changes that must survive deploys belong in the
Labels tab (the plugin's override) or the .env tab. Git stacks stay
editable: a broken deploy can always be fixed in place.

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

Under the project name, a git stack shows its branch and the short id of
the commit it has deployed ("main @ 23cf507", or "not deployed"), and
"failed" when its last deploy failed. It is read from the stack's own
files, with no git command, so the list stays as fast as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Check for Changes" asks the repository for the branch's latest commit,
as compose-git check does, and gives one of three answers:

- up to date;
- a newer commit changes the stack's folder (the folder holding its
  compose file in the repository): Pull and Redeploy is offered;
- a newer commit changes only files outside that folder: in a repository
  that holds several stacks, usually a commit to another stack. The
  dialog says so, and why a stack can still need it (a bind mount or
  build context of ../something, an env_file, extends or include in
  another folder), and offers Deploy anyway.

Until now any commit on the branch read as "a newer commit is waiting",
so in a shared repository every stack looked behind after a commit to
one of them. The folder test is the one deploys already use to decide
whether to recreate containers (GitClone::stackFilesChanged()).

When a newer commit's changes cannot be identified, the answer is the
second one, and the dialog says why: nothing is deployed yet, or the
deployed commit is no longer in the clone (deployedCommitMissing; it
was made again after a force-push, say).

To compare, a newer commit is fetched into the clone under the stack's
lock, as a deploy would fetch it; no container and no checked-out file
changes. A check while another operation on the stack is running says
so instead of waiting.

compose-git check gives the same answers: exit status 3 now means a
newer commit changes the stack's folder (or its changes cannot be
identified), and the new 4 means one changes only files outside it. With
--all, 3 wins, then 1 (a stack could not be checked), then 4. A script
that should deploy whenever the branch moves treats 4 like 3; the help
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sources tab of a stack that is not a git stack gets "Move this stack
into git...": the repository address, branch, compose file path and
credential, as in Add Stack. It runs the same code as compose-git
convert: the stack keeps its name, .env and the plugin's override, its
old compose file is moved into a dated pre-git folder in the stack
folder, and its containers change only at the next deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete removes only the stack folder, as for any stack. For a git stack
it now names the clone left on disk (instead of the compose file inside
it), and that an ssh stack's deploy key stays on the Credentials tab.
The clone is neither deleted nor moved: a container may keep data in it,
and once git.json is gone the plugin cannot prove the clone is its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The git stacks guide now covers the web UI next to the command line: the
quick start and the move-into-git steps say where each step is on the
Compose page, a new "In the web UI" section maps each screen to its
compose-git command, and "No web UI yet" leaves the limitations.

A new section, "The stack's folder, and files outside it", explains the
three answers of Check for Changes and compose-git check, the new exit
status 4 (a newer commit changes only files outside the stack's folder),
and what to do when a stack uses files from elsewhere in the repository.
The command reference adds exit status 4 and that check, like convert
and reclone, refuses rather than waits while another operation on the
stack is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mutation test, off unless E2E_ENABLE_MUTATION_TESTS=1 and a test
repository is given (E2E_GIT_TEST_REPO, E2E_GIT_TEST_COMPOSE_PATH). It
adds a GUID-named git stack in the Add Stack dialog, checks the editor's
banner and Sources tab, the stack list line and the stack menu's git
items, deploys it in the background, checks it is up to date, and
deletes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the compose file

The Sources tab listed a hand-made commit and changed files as one line
of orange text, easy to miss although the next deploy stops on them.
They now go in an orange-edged box like the deploy key panel, with a
warning icon, the files, and what to do: Pull and Redeploy can save them
as a patch and discard them, and a change to keep belongs in the
repository.

The same box is shown on the Compose tab above the file, where an edit
in the clone is most likely made or noticed.
Saving a file in the editor reads the clone again, so the box shows as
soon as the edit is saved.

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

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.

🟡 Changes recommended

Check-for-changes can return stale deployment state when a concurrent deployment completes during its remote query.

5 open findings
What changed in this PR

Adds web UI support for Git-backed stacks, completing part 3 of #138 by exposing existing Git stack operations through Compose Manager.

Changes:

  • Adds create, convert, inspect, check, deploy, and delete flows for Git stacks.
  • Aligns web and CLI deployment behavior, environment handling, and change detection.
  • Expands unit, Bats, Playwright, and documentation coverage.
File Description
tests/​unit/​GitStackWebActionsTest.php Tests web Git-stack actions.
tests/​unit/​GitStackManagerTest.php Tests checks, deploy commands, and exit codes.
tests/​unit/​GitSshTest.php Tests SSH host-key errors.
tests/​unit/​GitDeployTest.php Tests Compose working-directory resolution.
tests/​unit/​gitdeploy.bats Tests isolated deploy environments.
tests/​unit/​GitCloneTest.php Tests clone-failure classification.
tests/​unit/​CredentialVaultTest.php Clarifies credential callback coverage.
tests/​e2e/​tests/​compose-git-stacks.spec.ts Adds Git-stack UI lifecycle coverage.
tests/​e2e/​README.md Documents Git E2E configuration.
tests/​bootstrap.php Extends test logging and constants.
source/​compose.manager/​sheets/​EditorModal.css Styles Git source and warning panels.
source/​compose.manager/​scripts/​git_stack.php Exposes the Compose working directory.
source/​compose.manager/​scripts/​compose.sh Isolates Git deploy execution.
source/​compose.manager/​scripts/​compose_git.php Refines checks, statuses, and deployment.
source/​compose.manager/​scripts/​common.sh Supports configurable Docker invocation.
source/​compose.manager/​javascript/​credentialManager.js Updates Git credential guidance.
source/​compose.manager/​javascript/​composeManagerMain.js Implements Git-stack UI workflows.
source/​compose.manager/​include/​Helpers.php Builds and launches Git deploy commands.
source/​compose.manager/​include/​GitStackWebActions.php Adds the web action service.
source/​compose.manager/​include/​GitStackManager.php Adds richer checks and web support.
source/​compose.manager/​include/​GitSsh.php Improves host-key diagnostics.
source/​compose.manager/​include/​GitDeploy.php Exposes deploy working-directory logic.
source/​compose.manager/​include/​GitClone.php Classifies clone failures and local commits.
source/​compose.manager/​include/​Exec.php Registers Git-stack AJAX actions.
source/​compose.manager/​include/​ComposeUtil.php Routes Git deployment requests.
source/​compose.manager/​include/​ComposeManager.php Adds editor Git-source controls.
source/​compose.manager/​include/​ComposeList.php Shows Git branch and deployment state.
source/​compose.manager/​compose.manager.settings.page Generalizes the credentials heading.
docs/​git-stacks.md Documents web workflows and check semantics.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread source/compose.manager/include/GitStackManager.php Outdated
Comment thread source/compose.manager/include/ComposeManager.php Outdated
Comment thread source/compose.manager/include/Helpers.php Outdated
Comment thread source/compose.manager/javascript/composeManagerMain.js Outdated
Comment thread source/compose.manager/javascript/composeManagerMain.js Outdated
grantemsley and others added 3 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>
@grantemsley

Copy link
Copy Markdown
Author

Thanks, all five fixed as new commits on top:

  • Check for Changes could report a stale deployed commit: e0924f1. The remote is still asked before
    the stack's lock is taken, so a slow remote doesn't hold up a deploy. The deployed commit is then read
    under the lock and the answer is built from that. A check now waits for no one: it is refused while
    another operation on the stack is running, also when the stack is up to date.
  • Deploy key boxes had no accessible name: 760f098. The Sources tab's box is labelled by its help text,
    and the box in the Add Stack and Move into git panels gets an aria-label.
  • "40 characters" with SHA-256 repositories: 7a1b4df. The messages now say 40 characters, or 64 in a
    SHA-256 repository. That covers the prompt, its error, the server's error and compose-git deploy --help.

grantemsley and others added 2 commits October 12, 2026 17:37
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>
@grantemsley

Copy link
Copy Markdown
Author

Two more commits on top, for something I ran into while setting up the Add Stack flow on my own server.

My stacks live in one repository, one folder per stack. Over ssh, every stack got a deploy key of its own, so
the repository ended up needing one deploy 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,
so one key per repository is enough. The Add Stack dialog is where this shows: for the second stack it asks you
to add yet another key.

  • b040a7e: a new ssh stack uses the key of another stack of the same repository (same user, host, port and
    path), so it clones at once with nothing to add. A new key is now named after the repository. A key named
    after a stack by the current code is still found on a retry, as long as no other stack uses it.
  • f515f49: compose-git credential <stack> <key> can move an existing ssh stack onto another deploy key of its
    repository, so stacks set up with a key each can move onto one. Anything else is still refused for an ssh stack.

Tested with the unit suite and over real ssh against a local Forgejo on an Unraid VM. I can split these into a
separate PR if you'd rather keep #178 to the UI.

@mstrhakr
mstrhakr merged commit 5d09aad into mstrhakr:dev Oct 13, 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.

3 participants