diff --git a/frontend/index.html b/frontend/index.html
index af3490b..42dd781 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -390,6 +390,7 @@ const api = {
deploy: (n) => api.request(`/apps/${n}/deploy`, "POST"),
restart: (n) => api.request(`/apps/${n}/restart`, "POST"),
stop: (n) => api.request(`/apps/${n}/stop`, "POST"),
+ repoPull: (n) => api.request(`/apps/${n}/repo-pull`, "POST"),
remove: (n, keep) => api.request(`/apps/${n}/remove`, "POST", { keepVolumes: keep }),
saveCompose: (n, c) => api.request(`/apps/${n}/compose`, "POST", { content: c }),
validateCompose: (n) => api.request(`/apps/${n}/validate-compose`, "POST"),
@@ -534,6 +535,7 @@ function renderAppCard(app) {
+
@@ -699,6 +701,12 @@ async function handleAction(action, name, btnElement) {
setStatus(`Stopped ${name}.`);
await loadApps();
break;
+ case "repo-pull":
+ setStatus(`Pulling repo for ${name}...`);
+ await api.repoPull(name);
+ setStatus(`Repo pulled for ${name}.`);
+ await loadApps();
+ break;
case "remove": {
const keep = confirm("Keep volumes? OK = keep, Cancel = delete everything.");
setStatus(`Removing ${name}...`);
diff --git a/panel-api.py b/panel-api.py
index e320f88..0aa7847 100644
--- a/panel-api.py
+++ b/panel-api.py
@@ -738,6 +738,98 @@ class Handler(BaseHTTPRequestHandler):
self._json(200 if result["ok"] else 400, result)
return
+ # POST /apps//repo-pull — re-clone/pull repo and redeploy
+ if action == "repo-pull":
+ if not is_safe_name(name):
+ self._json(400, {"ok": False, "error": "invalid app name"})
+ return
+
+ try:
+ app, err = read_app_info(name)
+ if err is not None or app is None:
+ self._json(404, {"ok": False, "error": "app not found"})
+ return
+
+ repo_url = app.get("APP_REPO_URL", "").strip()
+ branch = app.get("APP_REPO_BRANCH", "main").strip()
+
+ if not repo_url:
+ self._json(400, {"ok": False, "error": "app has no APP_REPO_URL"})
+ return
+
+ git_bin = shutil.which("git")
+ if not git_bin:
+ self._json(400, {"ok": False, "error": "git is not installed"})
+ return
+
+ target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
+ pat = ""
+
+ auth_url = repo_url
+ if pat:
+ auth_url = auth_url.replace("://", f"://{pat}@")
+ if not auth_url.endswith(".git"):
+ auth_url += ".git"
+
+ if os.path.exists(target_dir):
+ # Already cloned — try git pull
+ pull_result = subprocess.run(
+ [git_bin, "-C", target_dir, "pull", "origin", branch],
+ capture_output=True, text=True
+ )
+ if pull_result.returncode != 0:
+ # Fall back to re-clone
+ shutil.rmtree(target_dir)
+ clone_result = subprocess.run(
+ [git_bin, "clone", "--branch", branch, auth_url, target_dir],
+ capture_output=True, text=True
+ )
+ if clone_result.returncode != 0:
+ self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"})
+ return
+ else:
+ clone_result = subprocess.run(
+ [git_bin, "clone", "--branch", branch, auth_url, target_dir],
+ capture_output=True, text=True
+ )
+ if clone_result.returncode != 0:
+ self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"})
+ return
+
+ # Find compose file
+ compose_path = None
+ for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]:
+ candidate = os.path.join(target_dir, fname)
+ if os.path.isfile(candidate):
+ compose_path = candidate
+ break
+
+ if not compose_path:
+ self._json(400, {"ok": False, "error": "compose file not found in repository"})
+ return
+
+ # Update manifest compose path
+ manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env")
+ try:
+ with open(manifest_path, "r", encoding="utf-8") as fh:
+ lines = fh.readlines()
+ with open(manifest_path, "w", encoding="utf-8") as fh:
+ for line in lines:
+ if line.startswith("APP_COMPOSE_FILE="):
+ fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n')
+ else:
+ fh.write(line)
+ except OSError as exc:
+ self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
+ return
+
+ # Redeploy
+ result = run_panelctl(["deploy", name])
+ self._json(200 if result["ok"] else 400, result)
+ except Exception as exc:
+ self._json(500, {"ok": False, "error": f"repo-pull failed: {exc}"})
+ return
+
# POST /apps//remove
if action == "remove":
if not is_safe_name(name):