cyclo-manager 0.1.1.dev0__tar.gz → 0.2.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/PKG-INFO +4 -1
  2. cyclo_manager-0.2.0.dev0/cyclo_host_agent/__init__.py +19 -0
  3. cyclo_manager-0.2.0.dev0/cyclo_host_agent/main.py +57 -0
  4. cyclo_manager-0.2.0.dev0/cyclo_host_agent/models.py +96 -0
  5. cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/__init__.py +19 -0
  6. cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/repos.py +359 -0
  7. cyclo_manager-0.2.0.dev0/cyclo_host_agent/routers/update.py +175 -0
  8. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/PKG-INFO +4 -1
  9. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/SOURCES.txt +6 -0
  10. cyclo_manager-0.2.0.dev0/cyclo_manager.egg-info/entry_points.txt +4 -0
  11. cyclo_manager-0.2.0.dev0/cyclo_manager.egg-info/requires.txt +7 -0
  12. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/top_level.txt +1 -0
  13. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/__init__.py +1 -1
  14. cyclo_manager-0.2.0.dev0/cyclo_manager_cli/cli.py +274 -0
  15. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/docker/docker-compose.yml +4 -2
  16. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/pyproject.toml +9 -3
  17. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/setup.cfg +1 -1
  18. cyclo_manager-0.1.1.dev0/cyclo_manager.egg-info/entry_points.txt +0 -2
  19. cyclo_manager-0.1.1.dev0/cyclo_manager.egg-info/requires.txt +0 -4
  20. cyclo_manager-0.1.1.dev0/cyclo_manager_cli/cli.py +0 -189
  21. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/README.md +0 -0
  22. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  23. {cyclo_manager-0.1.1.dev0 → cyclo_manager-0.2.0.dev0}/cyclo_manager_cli/config/config.yml +0 -0
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.1.1.dev0
3
+ Version: 0.2.0.dev0
4
4
  Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi>=0.100.0
9
+ Requires-Dist: uvicorn[standard]>=0.23.0
10
+ Requires-Dist: psutil>=5.9.0
8
11
  Provides-Extra: dev
9
12
  Requires-Dist: pytest; extra == "dev"
10
13
  Requires-Dist: ruff; extra == "dev"
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """cyclo_host_agent: host-side agent for system stats and repo management."""
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Cyclo host agent: FastAPI server on Unix Domain Socket for host-level operations."""
20
+
21
+ import logging
22
+ from pathlib import Path
23
+
24
+ import uvicorn
25
+ from fastapi import FastAPI
26
+
27
+ from cyclo_host_agent.routers import repos, update
28
+
29
+ logging.basicConfig(
30
+ level=logging.INFO,
31
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+ SOCKET_PATH = '/var/run/robotis/agent_sockets/host/host_agent.sock'
36
+
37
+ app = FastAPI(
38
+ title='cyclo_host_agent',
39
+ description='Host agent for Cyclo Manager: repo management.',
40
+ version='0.2.0',
41
+ )
42
+
43
+ app.include_router(repos.router)
44
+ app.include_router(update.router)
45
+
46
+
47
+ def main() -> None:
48
+ """Entry point for the host agent; called by systemd via ExecStart."""
49
+ socket_path = Path(SOCKET_PATH)
50
+ socket_path.parent.mkdir(parents=True, exist_ok=True)
51
+ socket_path.unlink(missing_ok=True)
52
+ logger.info('Starting on %s', socket_path)
53
+ uvicorn.run(app, uds=str(socket_path), log_level='info')
54
+
55
+
56
+ if __name__ == '__main__':
57
+ main()
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Pydantic models for cyclo_host_agent API."""
20
+
21
+ from typing import Optional
22
+
23
+ from pydantic import BaseModel
24
+
25
+
26
+ class RepoInfo(BaseModel):
27
+ """Single git repository info."""
28
+
29
+ name: str
30
+ path: str
31
+ branch: Optional[str] = None
32
+ remote: Optional[str] = None
33
+
34
+
35
+ class RepoListResponse(BaseModel):
36
+ """Response for GET /repos."""
37
+
38
+ repos: list[RepoInfo]
39
+ workspace_path: str
40
+
41
+
42
+ class RepoUpdateStatus(BaseModel):
43
+ """Version update status for a single repository."""
44
+
45
+ name: str
46
+ current_version: Optional[str] = None
47
+ latest_version: Optional[str] = None
48
+ has_update: bool
49
+
50
+
51
+ class RepoUpdatesResponse(BaseModel):
52
+ """Response for GET /repos/updates."""
53
+
54
+ repos: list[RepoUpdateStatus]
55
+ workspace_path: str
56
+
57
+
58
+ class FileChange(BaseModel):
59
+ """A single entry from git status --porcelain."""
60
+
61
+ path: str
62
+ status: str # e.g. M, D, ??, etc.
63
+
64
+
65
+ class RepoStatusResponse(BaseModel):
66
+ """Response for GET /repos/{name}/status."""
67
+
68
+ name: str
69
+ changes: list[FileChange]
70
+ has_changes: bool
71
+
72
+
73
+ class UpdateRequest(BaseModel):
74
+ """Request body for POST /repos/{name}/update."""
75
+
76
+ strategy: str # 'stash' | 'reset'
77
+ preserve_files: list[str] = [] # files to keep when using the reset strategy
78
+
79
+
80
+ class UpdateResponse(BaseModel):
81
+ """Response for POST /repos/{name}/update."""
82
+
83
+ name: str
84
+ success: bool
85
+ output: str
86
+ stash_conflict: bool = False
87
+ stash_conflict_files: list[str] = []
88
+
89
+
90
+ class ContainerScriptResponse(BaseModel):
91
+ """Response for POST /repos/{name}/container/{action}."""
92
+
93
+ name: str
94
+ action: str
95
+ success: bool
96
+ output: str
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Routers package for cyclo_host_agent."""
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Repository management endpoints."""
20
+
21
+ import asyncio
22
+ import json
23
+ import os
24
+ import re
25
+ import urllib.request
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ from fastapi import APIRouter, HTTPException, status
30
+
31
+ from cyclo_host_agent.models import (
32
+ ContainerScriptResponse,
33
+ FileChange,
34
+ RepoInfo,
35
+ RepoListResponse,
36
+ RepoStatusResponse,
37
+ RepoUpdateStatus,
38
+ RepoUpdatesResponse,
39
+ UpdateRequest,
40
+ UpdateResponse,
41
+ )
42
+
43
+ router = APIRouter(prefix='/repos', tags=['repos'])
44
+
45
+ GIT_TIMEOUT = 120.0
46
+
47
+
48
+ def _resolve_home() -> Path:
49
+ sudo_user = os.environ.get('SUDO_USER')
50
+ if sudo_user:
51
+ return Path(f'/home/{sudo_user}')
52
+ return Path.home()
53
+
54
+
55
+ WORKSPACE_PATH = _resolve_home()
56
+ MANAGED_GITHUB_ORG = 'ROBOTIS-GIT'
57
+
58
+
59
+ # ── shared helpers ─────────────────────────────────────────────────────────────
60
+
61
+ def _get_repo(name: str) -> Path:
62
+ """Return the repo path, raising HTTPException if it does not exist or is not a git repo."""
63
+ repo_path = WORKSPACE_PATH / name
64
+ if not repo_path.is_dir():
65
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
66
+ detail=f"Repo '{name}' not found")
67
+ if not (repo_path / '.git').exists():
68
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
69
+ detail=f"'{name}' is not a git repository")
70
+ return repo_path
71
+
72
+
73
+ def _scan_repo_paths() -> list[Path]:
74
+ """Return all directories under WORKSPACE_PATH that contain a .git folder."""
75
+ if not WORKSPACE_PATH.exists():
76
+ return []
77
+ return [
78
+ item for item in sorted(WORKSPACE_PATH.iterdir())
79
+ if item.is_dir() and (item / '.git').exists()
80
+ ]
81
+
82
+
83
+ def _fmt_cmd(cmd: str, output: str) -> str:
84
+ return f'$ {cmd}\n{output.strip()}' if output.strip() else f'$ {cmd}'
85
+
86
+
87
+ # ── git helpers ────────────────────────────────────────────────────────────────
88
+
89
+ async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
90
+ proc = await asyncio.create_subprocess_exec(
91
+ 'git', *args,
92
+ cwd=str(cwd) if cwd else None,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ )
96
+ try:
97
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=GIT_TIMEOUT)
98
+ except asyncio.TimeoutError:
99
+ proc.kill()
100
+ await proc.wait()
101
+ raise
102
+ return proc.returncode, stdout.decode(), stderr.decode()
103
+
104
+
105
+ async def _repo_info(repo_path: Path) -> RepoInfo:
106
+ branch: Optional[str] = None
107
+ remote: Optional[str] = None
108
+ try:
109
+ rc, out, _ = await _git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo_path)
110
+ if rc == 0:
111
+ branch = out.strip()
112
+ rc, out, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
113
+ if rc == 0:
114
+ remote = out.strip()
115
+ except Exception:
116
+ pass
117
+ return RepoInfo(name=repo_path.name, path=str(repo_path), branch=branch, remote=remote)
118
+
119
+
120
+ # ── version check helpers ──────────────────────────────────────────────────────
121
+
122
+ def _parse_version(version_str: str) -> tuple[int, ...]:
123
+ parts = []
124
+ for p in (version_str or '').strip().lstrip('v').split('.'):
125
+ try:
126
+ parts.append(int(p))
127
+ except ValueError:
128
+ parts.append(0)
129
+ return tuple(parts) if parts else (0,)
130
+
131
+
132
+ def _is_newer(latest: str, current: str) -> bool:
133
+ return _parse_version(latest) > _parse_version(current)
134
+
135
+
136
+ def _parse_github_slug(remote_url: str) -> Optional[str]:
137
+ https_match = re.match(r'https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$', remote_url)
138
+ if https_match:
139
+ return https_match.group(1)
140
+ ssh_match = re.match(r'git@github\.com:([^/]+/[^/]+?)(?:\.git)?$', remote_url)
141
+ if ssh_match:
142
+ return ssh_match.group(1)
143
+ return None
144
+
145
+
146
+ def _is_managed_remote(remote_url: str) -> bool:
147
+ slug = _parse_github_slug(remote_url)
148
+ if not slug:
149
+ return False
150
+ return slug.split('/')[0].lower() == MANAGED_GITHUB_ORG.lower()
151
+
152
+
153
+ def _read_package_xml_version(repo_path: Path) -> Optional[str]:
154
+ candidates = [repo_path / 'package.xml'] + sorted(repo_path.glob('*/package.xml'))
155
+ for candidate in candidates:
156
+ if candidate.is_file():
157
+ try:
158
+ text = candidate.read_text(errors='replace')
159
+ m = re.search(r'<version>\s*([^<]+)\s*</version>', text)
160
+ if m:
161
+ return m.group(1).strip()
162
+ except OSError:
163
+ continue
164
+ return None
165
+
166
+
167
+ def _fetch_github_latest(slug: str) -> Optional[str]:
168
+ url = f'https://api.github.com/repos/{slug}/releases/latest'
169
+ req = urllib.request.Request(url, headers={'User-Agent': 'cyclo-manager/1.0'})
170
+ try:
171
+ with urllib.request.urlopen(req, timeout=10) as resp:
172
+ data = json.loads(resp.read().decode())
173
+ return (data.get('tag_name') or '').strip() or None
174
+ except Exception:
175
+ return None
176
+
177
+
178
+ async def _repo_update_status(repo_path: Path) -> Optional[RepoUpdateStatus]:
179
+ rc, remote_url, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
180
+ remote_url = remote_url.strip() if rc == 0 else ''
181
+
182
+ if not remote_url or not _is_managed_remote(remote_url):
183
+ return None
184
+
185
+ slug = _parse_github_slug(remote_url)
186
+ current = _read_package_xml_version(repo_path)
187
+ if not current:
188
+ return RepoUpdateStatus(name=repo_path.name, has_update=False)
189
+
190
+ latest = await asyncio.to_thread(_fetch_github_latest, slug)
191
+ if not latest:
192
+ return RepoUpdateStatus(name=repo_path.name, current_version=current, has_update=False)
193
+
194
+ return RepoUpdateStatus(
195
+ name=repo_path.name,
196
+ current_version=current,
197
+ latest_version=latest,
198
+ has_update=_is_newer(latest, current),
199
+ )
200
+
201
+
202
+ # ── update strategy helpers ────────────────────────────────────────────────────
203
+
204
+ async def _update_stash(repo_path: Path) -> UpdateResponse:
205
+ name = repo_path.name
206
+ lines: list[str] = []
207
+
208
+ rc, out, err = await _git(['stash', '-u'], cwd=repo_path)
209
+ lines.append(_fmt_cmd('git stash -u', out + err))
210
+ if rc != 0:
211
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
212
+
213
+ rc, out, err = await _git(['pull'], cwd=repo_path)
214
+ lines.append(_fmt_cmd('git pull', out + err))
215
+ if rc != 0:
216
+ await _git(['stash', 'pop'], cwd=repo_path)
217
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
218
+
219
+ rc, out, err = await _git(['stash', 'pop'], cwd=repo_path)
220
+ lines.append(_fmt_cmd('git stash pop', out + err))
221
+
222
+ stash_conflict = False
223
+ stash_conflict_files: list[str] = []
224
+ if rc != 0:
225
+ _, conflict_out, _ = await _git(
226
+ ['diff', '--name-only', '--diff-filter=U'], cwd=repo_path
227
+ )
228
+ stash_conflict_files = [f for f in conflict_out.splitlines() if f.strip()]
229
+ await _git(['checkout', 'stash@{0}', '--', '.'], cwd=repo_path)
230
+ await _git(['reset', 'HEAD', '.'], cwd=repo_path)
231
+ await _git(['stash', 'drop'], cwd=repo_path)
232
+ stash_conflict = True
233
+ lines.append(
234
+ 'Conflict detected — local changes preserved'
235
+ + (f': {", ".join(stash_conflict_files)}' if stash_conflict_files else '')
236
+ )
237
+
238
+ return UpdateResponse(
239
+ name=name, success=True, output='\n'.join(lines),
240
+ stash_conflict=stash_conflict, stash_conflict_files=stash_conflict_files,
241
+ )
242
+
243
+
244
+ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateResponse:
245
+ name = repo_path.name
246
+ lines: list[str] = []
247
+
248
+ backups: dict[str, bytes] = {}
249
+ for rel in preserve_files:
250
+ full = repo_path / rel
251
+ if full.is_file():
252
+ try:
253
+ backups[rel] = full.read_bytes()
254
+ except OSError:
255
+ pass
256
+
257
+ rc, out, err = await _git(['reset', '--hard', 'HEAD'], cwd=repo_path)
258
+ lines.append(_fmt_cmd('git reset --hard HEAD', out + err))
259
+
260
+ rc, out, err = await _git(['clean', '-fd'], cwd=repo_path)
261
+ lines.append(_fmt_cmd('git clean -fd', out + err))
262
+
263
+ rc, out, err = await _git(['pull'], cwd=repo_path)
264
+ lines.append(_fmt_cmd('git pull', out + err))
265
+ if rc != 0:
266
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
267
+
268
+ for rel, content in backups.items():
269
+ full = repo_path / rel
270
+ try:
271
+ full.parent.mkdir(parents=True, exist_ok=True)
272
+ full.write_bytes(content)
273
+ lines.append(f'Restored: {rel}')
274
+ except OSError as e:
275
+ lines.append(f'Restore failed: {rel} ({e})')
276
+
277
+ return UpdateResponse(name=name, success=True, output='\n'.join(lines))
278
+
279
+
280
+ # ── container helper ───────────────────────────────────────────────────────────
281
+
282
+ async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
283
+ script = repo_path / 'docker' / 'container.sh'
284
+ if not script.exists():
285
+ return False, f'container.sh not found at {script}'
286
+ proc = await asyncio.create_subprocess_exec(
287
+ 'bash', str(script), action,
288
+ cwd=str(repo_path / 'docker'),
289
+ stdin=asyncio.subprocess.PIPE,
290
+ stdout=asyncio.subprocess.PIPE,
291
+ stderr=asyncio.subprocess.PIPE,
292
+ )
293
+ try:
294
+ stdout, stderr = await asyncio.wait_for(proc.communicate(input=b'y\n'), timeout=300.0)
295
+ except asyncio.TimeoutError:
296
+ proc.kill()
297
+ await proc.wait()
298
+ return False, 'Timeout waiting for container.sh'
299
+ return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
300
+
301
+
302
+ # ── endpoints ──────────────────────────────────────────────────────────────────
303
+
304
+ @router.get('/updates', response_model=RepoUpdatesResponse)
305
+ async def get_repo_updates() -> RepoUpdatesResponse:
306
+ repo_paths = _scan_repo_paths()
307
+ raw = await asyncio.gather(*(_repo_update_status(p) for p in repo_paths))
308
+ statuses = [s for s in raw if s is not None]
309
+ return RepoUpdatesResponse(repos=statuses, workspace_path=str(WORKSPACE_PATH))
310
+
311
+
312
+ @router.get('', response_model=RepoListResponse)
313
+ async def list_repos() -> RepoListResponse:
314
+ candidates = _scan_repo_paths()
315
+ infos = await asyncio.gather(*(_repo_info(item) for item in candidates))
316
+ repos = [r for r in infos if r.remote and _is_managed_remote(r.remote)]
317
+ return RepoListResponse(repos=repos, workspace_path=str(WORKSPACE_PATH))
318
+
319
+
320
+ @router.get('/{name}/status', response_model=RepoStatusResponse)
321
+ async def get_repo_status(name: str) -> RepoStatusResponse:
322
+ repo_path = _get_repo(name)
323
+ rc, out, _ = await _git(['status', '--porcelain'], cwd=repo_path)
324
+ changes: list[FileChange] = []
325
+ for line in out.splitlines():
326
+ if not line.strip():
327
+ continue
328
+ code = line[0:2].rstrip()
329
+ path = line[3:].strip()
330
+ if ' -> ' in path:
331
+ path = path.split(' -> ', 1)[1]
332
+ changes.append(FileChange(path=path, status=code))
333
+ return RepoStatusResponse(name=name, changes=changes, has_changes=bool(changes))
334
+
335
+
336
+ @router.post('/{name}/container/stop', response_model=ContainerScriptResponse)
337
+ async def stop_repo_container(name: str) -> ContainerScriptResponse:
338
+ repo_path = _get_repo(name)
339
+ success, output = await _run_container_sh(repo_path, 'stop')
340
+ return ContainerScriptResponse(name=name, action='stop', success=success, output=output)
341
+
342
+
343
+ @router.post('/{name}/container/start', response_model=ContainerScriptResponse)
344
+ async def start_repo_container(name: str) -> ContainerScriptResponse:
345
+ repo_path = _get_repo(name)
346
+ success, output = await _run_container_sh(repo_path, 'start')
347
+ return ContainerScriptResponse(name=name, action='start', success=success, output=output)
348
+
349
+
350
+ @router.post('/{name}/update', response_model=UpdateResponse)
351
+ async def update_repo(name: str, req: UpdateRequest) -> UpdateResponse:
352
+ repo_path = _get_repo(name)
353
+ try:
354
+ if req.strategy == 'reset':
355
+ return await _update_reset(repo_path, req.preserve_files)
356
+ return await _update_stash(repo_path)
357
+ except asyncio.TimeoutError:
358
+ raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT,
359
+ detail='git operation timed out')