cyclo-manager 0.1.1.dev1__tar.gz → 0.2.0.dev1__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.dev1 → cyclo_manager-0.2.0.dev1}/PKG-INFO +4 -1
  2. cyclo_manager-0.2.0.dev1/cyclo_host_agent/__init__.py +19 -0
  3. cyclo_manager-0.2.0.dev1/cyclo_host_agent/main.py +57 -0
  4. cyclo_manager-0.2.0.dev1/cyclo_host_agent/models.py +96 -0
  5. cyclo_manager-0.2.0.dev1/cyclo_host_agent/routers/__init__.py +19 -0
  6. cyclo_manager-0.2.0.dev1/cyclo_host_agent/routers/repos.py +363 -0
  7. cyclo_manager-0.2.0.dev1/cyclo_host_agent/routers/update.py +175 -0
  8. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager.egg-info/PKG-INFO +4 -1
  9. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager.egg-info/SOURCES.txt +6 -0
  10. cyclo_manager-0.2.0.dev1/cyclo_manager.egg-info/entry_points.txt +4 -0
  11. cyclo_manager-0.2.0.dev1/cyclo_manager.egg-info/requires.txt +7 -0
  12. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager.egg-info/top_level.txt +1 -0
  13. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager_cli/__init__.py +1 -1
  14. cyclo_manager-0.2.0.dev1/cyclo_manager_cli/cli.py +276 -0
  15. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager_cli/docker/docker-compose.yml +4 -2
  16. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/pyproject.toml +9 -3
  17. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/setup.cfg +1 -1
  18. cyclo_manager-0.1.1.dev1/cyclo_manager.egg-info/entry_points.txt +0 -2
  19. cyclo_manager-0.1.1.dev1/cyclo_manager.egg-info/requires.txt +0 -4
  20. cyclo_manager-0.1.1.dev1/cyclo_manager_cli/cli.py +0 -189
  21. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/README.md +0 -0
  22. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  23. {cyclo_manager-0.1.1.dev1 → cyclo_manager-0.2.0.dev1}/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.dev1
3
+ Version: 0.2.0.dev1
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,363 @@
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 subprocess
26
+ import urllib.request
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from fastapi import APIRouter, HTTPException, status
31
+
32
+ from cyclo_host_agent.models import (
33
+ ContainerScriptResponse,
34
+ FileChange,
35
+ RepoInfo,
36
+ RepoListResponse,
37
+ RepoStatusResponse,
38
+ RepoUpdateStatus,
39
+ RepoUpdatesResponse,
40
+ UpdateRequest,
41
+ UpdateResponse,
42
+ )
43
+
44
+ router = APIRouter(prefix='/repos', tags=['repos'])
45
+
46
+ GIT_TIMEOUT = 120.0
47
+
48
+
49
+ def _resolve_home() -> Path:
50
+ sudo_user = os.environ.get('SUDO_USER')
51
+ if sudo_user:
52
+ return Path(f'/home/{sudo_user}')
53
+ return Path.home()
54
+
55
+
56
+ WORKSPACE_PATH = _resolve_home()
57
+ MANAGED_GITHUB_ORG = 'ROBOTIS-GIT'
58
+
59
+
60
+ # ── shared helpers ─────────────────────────────────────────────────────────────
61
+
62
+ def _get_repo(name: str) -> Path:
63
+ """Return the repo path, raising HTTPException if it does not exist or is not a git repo."""
64
+ repo_path = WORKSPACE_PATH / name
65
+ if not repo_path.is_dir():
66
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
67
+ detail=f"Repo '{name}' not found")
68
+ if not (repo_path / '.git').exists():
69
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
70
+ detail=f"'{name}' is not a git repository")
71
+ return repo_path
72
+
73
+
74
+ def _scan_repo_paths() -> list[Path]:
75
+ """Return all directories under WORKSPACE_PATH that contain a .git folder."""
76
+ if not WORKSPACE_PATH.exists():
77
+ return []
78
+ return [
79
+ item for item in sorted(WORKSPACE_PATH.iterdir())
80
+ if item.is_dir() and (item / '.git').exists()
81
+ ]
82
+
83
+
84
+ def _fmt_cmd(cmd: str, output: str) -> str:
85
+ return f'$ {cmd}\n{output.strip()}' if output.strip() else f'$ {cmd}'
86
+
87
+
88
+ # ── git helpers ────────────────────────────────────────────────────────────────
89
+
90
+ def _git_sync(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
91
+ try:
92
+ result = subprocess.run(
93
+ ['git', *args],
94
+ cwd=str(cwd) if cwd else None,
95
+ capture_output=True,
96
+ timeout=GIT_TIMEOUT,
97
+ )
98
+ return result.returncode, result.stdout.decode(), result.stderr.decode()
99
+ except subprocess.TimeoutExpired:
100
+ return 1, '', 'git timed out'
101
+ except Exception as e:
102
+ return 1, '', str(e)
103
+
104
+
105
+ async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
106
+ return await asyncio.to_thread(_git_sync, args, cwd)
107
+
108
+
109
+ async def _repo_info(repo_path: Path) -> RepoInfo:
110
+ branch: Optional[str] = None
111
+ remote: Optional[str] = None
112
+ try:
113
+ rc, out, _ = await _git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo_path)
114
+ if rc == 0:
115
+ branch = out.strip()
116
+ rc, out, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
117
+ if rc == 0:
118
+ remote = out.strip()
119
+ except Exception:
120
+ pass
121
+ return RepoInfo(name=repo_path.name, path=str(repo_path), branch=branch, remote=remote)
122
+
123
+
124
+ # ── version check helpers ──────────────────────────────────────────────────────
125
+
126
+ def _parse_version(version_str: str) -> tuple[int, ...]:
127
+ parts = []
128
+ for p in (version_str or '').strip().lstrip('v').split('.'):
129
+ try:
130
+ parts.append(int(p))
131
+ except ValueError:
132
+ parts.append(0)
133
+ return tuple(parts) if parts else (0,)
134
+
135
+
136
+ def _is_newer(latest: str, current: str) -> bool:
137
+ return _parse_version(latest) > _parse_version(current)
138
+
139
+
140
+ def _parse_github_slug(remote_url: str) -> Optional[str]:
141
+ https_match = re.match(r'https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$', remote_url)
142
+ if https_match:
143
+ return https_match.group(1)
144
+ ssh_match = re.match(r'git@github\.com:([^/]+/[^/]+?)(?:\.git)?$', remote_url)
145
+ if ssh_match:
146
+ return ssh_match.group(1)
147
+ return None
148
+
149
+
150
+ def _is_managed_remote(remote_url: str) -> bool:
151
+ slug = _parse_github_slug(remote_url)
152
+ if not slug:
153
+ return False
154
+ return slug.split('/')[0].lower() == MANAGED_GITHUB_ORG.lower()
155
+
156
+
157
+ def _read_package_xml_version(repo_path: Path) -> Optional[str]:
158
+ candidates = [repo_path / 'package.xml'] + sorted(repo_path.glob('*/package.xml'))
159
+ for candidate in candidates:
160
+ if candidate.is_file():
161
+ try:
162
+ text = candidate.read_text(errors='replace')
163
+ m = re.search(r'<version>\s*([^<]+)\s*</version>', text)
164
+ if m:
165
+ return m.group(1).strip()
166
+ except OSError:
167
+ continue
168
+ return None
169
+
170
+
171
+ def _fetch_github_latest(slug: str) -> Optional[str]:
172
+ url = f'https://api.github.com/repos/{slug}/releases/latest'
173
+ req = urllib.request.Request(url, headers={'User-Agent': 'cyclo-manager/1.0'})
174
+ try:
175
+ with urllib.request.urlopen(req, timeout=10) as resp:
176
+ data = json.loads(resp.read().decode())
177
+ return (data.get('tag_name') or '').strip() or None
178
+ except Exception:
179
+ return None
180
+
181
+
182
+ async def _repo_update_status(repo_path: Path) -> Optional[RepoUpdateStatus]:
183
+ rc, remote_url, _ = await _git(['remote', 'get-url', 'origin'], cwd=repo_path)
184
+ remote_url = remote_url.strip() if rc == 0 else ''
185
+
186
+ if not remote_url or not _is_managed_remote(remote_url):
187
+ return None
188
+
189
+ slug = _parse_github_slug(remote_url)
190
+ current = _read_package_xml_version(repo_path)
191
+ if not current:
192
+ return RepoUpdateStatus(name=repo_path.name, has_update=False)
193
+
194
+ latest = await asyncio.to_thread(_fetch_github_latest, slug)
195
+ if not latest:
196
+ return RepoUpdateStatus(name=repo_path.name, current_version=current, has_update=False)
197
+
198
+ return RepoUpdateStatus(
199
+ name=repo_path.name,
200
+ current_version=current,
201
+ latest_version=latest,
202
+ has_update=_is_newer(latest, current),
203
+ )
204
+
205
+
206
+ # ── update strategy helpers ────────────────────────────────────────────────────
207
+
208
+ async def _update_stash(repo_path: Path) -> UpdateResponse:
209
+ name = repo_path.name
210
+ lines: list[str] = []
211
+
212
+ rc, out, err = await _git(['stash', '-u'], cwd=repo_path)
213
+ lines.append(_fmt_cmd('git stash -u', out + err))
214
+ if rc != 0:
215
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
216
+
217
+ rc, out, err = await _git(['pull'], cwd=repo_path)
218
+ lines.append(_fmt_cmd('git pull', out + err))
219
+ if rc != 0:
220
+ await _git(['stash', 'pop'], cwd=repo_path)
221
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
222
+
223
+ rc, out, err = await _git(['stash', 'pop'], cwd=repo_path)
224
+ lines.append(_fmt_cmd('git stash pop', out + err))
225
+
226
+ stash_conflict = False
227
+ stash_conflict_files: list[str] = []
228
+ if rc != 0:
229
+ _, conflict_out, _ = await _git(
230
+ ['diff', '--name-only', '--diff-filter=U'], cwd=repo_path
231
+ )
232
+ stash_conflict_files = [f for f in conflict_out.splitlines() if f.strip()]
233
+ await _git(['checkout', 'stash@{0}', '--', '.'], cwd=repo_path)
234
+ await _git(['reset', 'HEAD', '.'], cwd=repo_path)
235
+ await _git(['stash', 'drop'], cwd=repo_path)
236
+ stash_conflict = True
237
+ lines.append(
238
+ 'Conflict detected — local changes preserved'
239
+ + (f': {", ".join(stash_conflict_files)}' if stash_conflict_files else '')
240
+ )
241
+
242
+ return UpdateResponse(
243
+ name=name, success=True, output='\n'.join(lines),
244
+ stash_conflict=stash_conflict, stash_conflict_files=stash_conflict_files,
245
+ )
246
+
247
+
248
+ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateResponse:
249
+ name = repo_path.name
250
+ lines: list[str] = []
251
+
252
+ backups: dict[str, bytes] = {}
253
+ for rel in preserve_files:
254
+ full = repo_path / rel
255
+ if full.is_file():
256
+ try:
257
+ backups[rel] = full.read_bytes()
258
+ except OSError:
259
+ pass
260
+
261
+ rc, out, err = await _git(['reset', '--hard', 'HEAD'], cwd=repo_path)
262
+ lines.append(_fmt_cmd('git reset --hard HEAD', out + err))
263
+
264
+ rc, out, err = await _git(['clean', '-fd'], cwd=repo_path)
265
+ lines.append(_fmt_cmd('git clean -fd', out + err))
266
+
267
+ rc, out, err = await _git(['pull'], cwd=repo_path)
268
+ lines.append(_fmt_cmd('git pull', out + err))
269
+ if rc != 0:
270
+ return UpdateResponse(name=name, success=False, output='\n'.join(lines))
271
+
272
+ for rel, content in backups.items():
273
+ full = repo_path / rel
274
+ try:
275
+ full.parent.mkdir(parents=True, exist_ok=True)
276
+ full.write_bytes(content)
277
+ lines.append(f'Restored: {rel}')
278
+ except OSError as e:
279
+ lines.append(f'Restore failed: {rel} ({e})')
280
+
281
+ return UpdateResponse(name=name, success=True, output='\n'.join(lines))
282
+
283
+
284
+ # ── container helper ───────────────────────────────────────────────────────────
285
+
286
+ async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
287
+ script = repo_path / 'docker' / 'container.sh'
288
+ if not script.exists():
289
+ return False, f'container.sh not found at {script}'
290
+ proc = await asyncio.create_subprocess_exec(
291
+ 'bash', str(script), action,
292
+ cwd=str(repo_path / 'docker'),
293
+ stdin=asyncio.subprocess.PIPE,
294
+ stdout=asyncio.subprocess.PIPE,
295
+ stderr=asyncio.subprocess.PIPE,
296
+ )
297
+ try:
298
+ stdout, stderr = await asyncio.wait_for(proc.communicate(input=b'y\n'), timeout=300.0)
299
+ except asyncio.TimeoutError:
300
+ proc.kill()
301
+ await proc.wait()
302
+ return False, 'Timeout waiting for container.sh'
303
+ return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
304
+
305
+
306
+ # ── endpoints ──────────────────────────────────────────────────────────────────
307
+
308
+ @router.get('/updates', response_model=RepoUpdatesResponse)
309
+ async def get_repo_updates() -> RepoUpdatesResponse:
310
+ repo_paths = _scan_repo_paths()
311
+ raw = await asyncio.gather(*(_repo_update_status(p) for p in repo_paths))
312
+ statuses = [s for s in raw if s is not None]
313
+ return RepoUpdatesResponse(repos=statuses, workspace_path=str(WORKSPACE_PATH))
314
+
315
+
316
+ @router.get('', response_model=RepoListResponse)
317
+ async def list_repos() -> RepoListResponse:
318
+ candidates = _scan_repo_paths()
319
+ infos = await asyncio.gather(*(_repo_info(item) for item in candidates))
320
+ repos = [r for r in infos if r.remote and _is_managed_remote(r.remote)]
321
+ return RepoListResponse(repos=repos, workspace_path=str(WORKSPACE_PATH))
322
+
323
+
324
+ @router.get('/{name}/status', response_model=RepoStatusResponse)
325
+ async def get_repo_status(name: str) -> RepoStatusResponse:
326
+ repo_path = _get_repo(name)
327
+ rc, out, _ = await _git(['status', '--porcelain'], cwd=repo_path)
328
+ changes: list[FileChange] = []
329
+ for line in out.splitlines():
330
+ if not line.strip():
331
+ continue
332
+ code = line[0:2].rstrip()
333
+ path = line[3:].strip()
334
+ if ' -> ' in path:
335
+ path = path.split(' -> ', 1)[1]
336
+ changes.append(FileChange(path=path, status=code))
337
+ return RepoStatusResponse(name=name, changes=changes, has_changes=bool(changes))
338
+
339
+
340
+ @router.post('/{name}/container/stop', response_model=ContainerScriptResponse)
341
+ async def stop_repo_container(name: str) -> ContainerScriptResponse:
342
+ repo_path = _get_repo(name)
343
+ success, output = await _run_container_sh(repo_path, 'stop')
344
+ return ContainerScriptResponse(name=name, action='stop', success=success, output=output)
345
+
346
+
347
+ @router.post('/{name}/container/start', response_model=ContainerScriptResponse)
348
+ async def start_repo_container(name: str) -> ContainerScriptResponse:
349
+ repo_path = _get_repo(name)
350
+ success, output = await _run_container_sh(repo_path, 'start')
351
+ return ContainerScriptResponse(name=name, action='start', success=success, output=output)
352
+
353
+
354
+ @router.post('/{name}/update', response_model=UpdateResponse)
355
+ async def update_repo(name: str, req: UpdateRequest) -> UpdateResponse:
356
+ repo_path = _get_repo(name)
357
+ try:
358
+ if req.strategy == 'reset':
359
+ return await _update_reset(repo_path, req.preserve_files)
360
+ return await _update_stash(repo_path)
361
+ except asyncio.TimeoutError:
362
+ raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT,
363
+ detail='git operation timed out')