Skip to content

Commit 6ed7eed

Browse files
committed
feat(compose): adopt EXTERNAL git projects — Pull/Redeploy a checkout DockGate didn't deploy (v2.1.25)
When a running compose project's working_dir is a git checkout DockGate did NOT deploy (user SSH'd in, git-cloned, and ran docker compose by hand), the detail modal now shows a 'Git (detected)' card with ⤓ Pull / ↻ Redeploy. Strictly non-destructive: runs PLAIN git in the user's own checkout (their own remote + creds — no token/key injection) and is FAST-FORWARD ONLY (fetch + merge --ff-only). Never reset --hard / clean. Refuses (changes nothing) on a dirty tree, detached HEAD, no upstream, or non-fast-forward. Redeploy runs docker compose -p <project> -f <configFile> up -d --build in the working dir (no --force-recreate by default). Works for the active server, local or remote SSH (git + compose over the same channel). Detection is lazy (detail-modal only, 90s cache) — zero git/SSH work in the list. DockGate-managed projects excluded. Untrusted labels validated (isSafeHostPath) + shq-quoted; git via git -C resolved by rev-parse --show-toplevel. New: GET /:project/git-detect, POST /:project/adopt-pull, POST /:project/adopt-redeploy (reuse execRemote/shq, runCompose/runComposeInRemoteDir, deployJobs/openDeployLog). Verified e2e (local): detect → ff pull → redeploy → dirty-refused(409) → non-git & managed excluded. Remote command construction + injection-rejection verified.
1 parent 1b603f0 commit 6ed7eed

5 files changed

Lines changed: 228 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
---
44

5+
## [2.1.25] - 2026-06-27
6+
7+
### Added — Adopt EXTERNAL git projects: Pull / Redeploy a checkout DockGate didn't deploy
8+
- Until now the git badge + **⤓ Pull** / **↻ Redeploy** only appeared for projects deployed *through* DockGate. If you'd SSH'd into your server, `git clone`d a repo yourself, and run `docker compose up` by hand, DockGate listed the project but offered no git actions. Now it **detects** that case: when you open a project's detail and its working directory is a git checkout DockGate doesn't manage, a **"Git (detected)"** card appears with **⤓ Pull** and **↻ Redeploy**.
9+
- **Strictly non-destructive.** Pull runs **plain `git` in your own checkout** (your already-configured remote + credentials — DockGate injects no token, overrides no SSH key) and is **fast-forward only** (`fetch` + `merge --ff-only`). It **never** runs `reset --hard` or `clean`, so it can't overwrite your files. If the working tree has uncommitted changes, is on a detached HEAD, has no upstream, or isn't a clean fast-forward, it **refuses and changes nothing**, telling you why. Redeploy then runs `docker compose -p <project> -f <configFile> up -d --build` in the working dir (one-shot/stateful services are **not** force-recreated).
10+
- Works for the **active server** — local Docker **or** a remote SSH host (git + compose run over the same SSH channel). Detection is **lazy** (only when the detail modal opens, 90s-cached) so the project list does zero git/SSH work. DockGate-managed projects are excluded (no double-handling).
11+
- **Security:** Docker `working_dir`/`config_files` labels are treated as untrusted — every path is validated (absolute, no `..`, no newline/NUL, strict charset) and shq-quoted before reaching a shell; git runs via `git -C <dir>` resolved through `rev-parse --show-toplevel`.
12+
- Verified e2e (local) against a real external checkout with an upstream: detect → fast-forward pull (files updated in place) → redeploy (`up -d --build`, container recreated) → **dirty tree refused (409, files untouched)** → non-git and DockGate-managed projects correctly offer nothing. Remote SSH path's command construction + injection-rejection verified separately.
13+
14+
---
15+
516
## [2.1.24] - 2026-06-27
617

718
### Added — Project Terminal: resizable (Normal / Large / Full screen)

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<p align="center">
2-
<img src="https://img.shields.io/badge/DockGate-v2.1.24-00d4aa?style=for-the-badge&logo=docker&logoColor=white" alt="DockGate">
2+
<img src="https://img.shields.io/badge/DockGate-v2.1.25-00d4aa?style=for-the-badge&logo=docker&logoColor=white" alt="DockGate">
33
<img src="https://img.shields.io/badge/Node.js-18-339933?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node.js">
44
<img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License">
5-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Changelog-v2.1.24-orange?style=for-the-badge" alt="Changelog"></a>
5+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Changelog-v2.1.25-orange?style=for-the-badge" alt="Changelog"></a>
66
<img src="https://img.shields.io/badge/CPU-≤0.5_core-brightgreen?style=for-the-badge" alt="CPU">
77
<img src="https://img.shields.io/badge/RAM-<256MB-success?style=for-the-badge" alt="RAM">
88
</p>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dockgate",
3-
"version": "2.1.24",
3+
"version": "2.1.25",
44
"description": "DockGate — A lightweight, browser-based Docker control panel",
55
"main": "server/index.js",
66
"author": "Ali Zeynalli",

public/js/pages/compose.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,56 @@ Router.register('compose', async (content) => {
185185
</div>
186186
`, [{ label: 'Close', className: 'btn btn-secondary' }]);
187187
dm.overlay.querySelector('#cd-copy-hook')?.addEventListener('click', () => navigator.clipboard?.writeText(webhookUrl).then(() => showToast('Copied', 'success', 2000)));
188+
189+
// External git checkout? (DockGate didn't deploy this, but its folder is a git repo on the host.)
190+
// Probe lazily; if it's an adoptable checkout, inject a "Git (detected)" card with ⤓ Pull / ↻ Redeploy.
191+
if (!git.gitManaged) {
192+
API.get(`/compose/${name}/git-detect`).then(d => {
193+
if (!d || !d.isGit || d.managed) return;
194+
const grid = dm.overlay.querySelector('.detail-grid');
195+
if (!grid) return;
196+
const sh = s => (s || '').slice(0, 7);
197+
const where = d.remote ? 'on the active remote server' : 'on local Docker';
198+
const card = document.createElement('div');
199+
card.className = 'card mb-2';
200+
card.style.cssText = 'padding:12px;background:var(--accent-dim)';
201+
card.innerHTML = `
202+
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
203+
<div class="text-sm" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;opacity:.85"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg><strong>Git (detected)</strong> — ${d.remoteUrl ? `<code>${escapeHtml(d.remoteUrl)}</code> @ ` : ''}<code>${escapeHtml(d.branch || 'HEAD')}</code></div>
204+
<div style="display:flex;gap:6px;flex:none">
205+
<button class="btn btn-sm btn-secondary" id="xg-pull" title="Fast-forward this checkout from its own git remote — does not deploy">⤓ Pull</button>
206+
<button class="btn btn-sm btn-primary" id="xg-redeploy" title="Pull (fast-forward), then docker compose up -d --build">↻ Redeploy…</button>
207+
</div>
208+
</div>
209+
<div class="text-xs text-muted" style="margin-top:6px">DockGate didn't deploy this — it's your own git checkout ${where} (<code>${escapeHtml(d.repoRoot)}</code>). Pull is <strong>fast-forward only</strong> and never resets your files.${d.canPull ? '' : ` <span style="color:var(--warning)">⚠ ${escapeHtml(d.reason || 'Pull unavailable')}.</span>`}</div>`;
210+
grid.parentNode.insertBefore(card, grid);
211+
const pullBtn = card.querySelector('#xg-pull');
212+
const redBtn = card.querySelector('#xg-redeploy');
213+
if (!d.canPull) { pullBtn.disabled = true; pullBtn.style.opacity = '0.5'; pullBtn.style.cursor = 'not-allowed'; }
214+
pullBtn.addEventListener('click', async () => {
215+
pullBtn.disabled = true; pullBtn.textContent = 'Pulling…';
216+
try {
217+
const r = await API.post(`/compose/${name}/adopt-pull`, {});
218+
dm.close();
219+
const body = r.upToDate
220+
? `<div class="text-sm">✓ Already up to date (<span class="td-mono">${sh(r.toSHA)}</span>) — nothing to pull.</div>`
221+
: `<div class="text-sm" style="margin-bottom:4px">⤓ Fast-forwarded <span class="td-mono">${sh(r.fromSHA)}</span> → <span class="td-mono">${sh(r.toSHA)}</span></div>${(r.commits && r.commits.length) ? `<pre class="logs-viewer" style="max-height:170px;overflow:auto;font-size:11px;white-space:pre-wrap;word-break:break-all;margin:6px 0">${r.commits.map(c => `${escapeHtml(c.hash || '')} ${escapeHtml(c.date || '')} ${escapeHtml(c.subject || '')}`).join('\n')}</pre>` : ''}<div class="text-sm" style="margin:8px 0 4px">📦 <strong>${(r.changed || []).length}</strong> file(s) changed:</div><pre class="logs-viewer" style="max-height:200px;overflow:auto;font-size:11px;white-space:pre-wrap;word-break:break-all;margin:0">${(r.changed || []).map(escapeHtml).join('\n')}</pre><div class="text-xs text-muted" style="margin-top:6px">Pulled — nothing was deployed. Use ↻ Redeploy to apply it to the containers.</div>`;
222+
showModal(`Pull — ${escapeHtml(name)}`, body, [{ label: 'Close', className: 'btn btn-secondary' }]);
223+
render();
224+
} catch (err) {
225+
pullBtn.disabled = false; pullBtn.textContent = '⤓ Pull';
226+
showModal(`Pull failed — ${escapeHtml(name)}`, `<div class="text-sm" style="color:var(--danger);white-space:pre-wrap;word-break:break-all">${escapeHtml(err.message)}</div>`, [{ label: 'Close', className: 'btn btn-secondary' }]);
227+
}
228+
});
229+
redBtn.addEventListener('click', () => {
230+
dm.close();
231+
showConfirm('Redeploy (external git)', `Pull (fast-forward) <strong>${escapeHtml(name)}</strong> then run:<br><code>docker compose -p ${escapeHtml(name)} -f ${escapeHtml(d.configFiles || '')} up -d --build</code><br>in <code>${escapeHtml(d.workingDir)}</code> ${where}.<br><span class="text-xs text-muted">One-shot/stateful services are not force-recreated.</span>`, async () => {
232+
try { const r = await API.post(`/compose/${name}/adopt-redeploy`, {}); if (r && r.jobId) openDeployLog(r.jobId, name); render(); }
233+
catch (err) { showToast(err.message, 'error', 12000); }
234+
});
235+
});
236+
}).catch(() => {});
237+
}
188238
dm.overlay.querySelector('#cd-redeploy')?.addEventListener('click', async (e) => {
189239
// Change-aware redeploy: pull latest → show what changed → SAME picker (only changed stacks
190240
// pre-selected) → deploy the chosen stacks/services. "Stage" = pull only, don't run.

0 commit comments

Comments
 (0)