taskunity 2026.1__py3-none-any.whl

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.
@@ -0,0 +1,598 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ import shutil
8
+ import subprocess
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .models import Attachment, Milestone, Note, Project, Task, TaskActivityEvent
14
+
15
+ DEFAULT_WORKSPACE_APP_NAME = "Taskunity"
16
+ DEFAULT_WORKSPACE_DESCRIPTION = "Local file-backed workspace/task tracker"
17
+
18
+
19
+ class WorkspaceError(RuntimeError):
20
+ pass
21
+
22
+
23
+ # Compiled once: task/milestone IDs are uppercase hex words joined by dashes.
24
+ _ID_RE = re.compile(r'^[A-Z0-9][A-Z0-9\-]*[A-Z0-9]$|^[A-Z0-9]$')
25
+
26
+
27
+ def _safe_id(value: str, label: str = "id") -> str:
28
+ """Validate *value* is a safe ID token, otherwise raise WorkspaceError.
29
+
30
+ This is a lightweight pre-check. Actual path confinement is enforced by
31
+ ``_safe_subpath``; always prefer that function when constructing file paths.
32
+ """
33
+ clean = (value or "").strip()
34
+ if not clean or not _ID_RE.match(clean) or ".." in clean or "/" in clean or "\\" in clean:
35
+ raise WorkspaceError(f"Invalid {label}: {value!r}")
36
+ return clean
37
+
38
+
39
+ def _safe_subpath(base: Path, *parts: str) -> Path:
40
+ """Build ``base / parts`` and verify the result is confined within *base*.
41
+
42
+ Uses ``os.path.normpath`` to collapse ``..`` segments so that a crafted
43
+ component such as ``../../etc/passwd`` resolves outside the workspace and is
44
+ rejected. This is the canonical path-injection mitigation pattern.
45
+ """
46
+ joined = os.path.join(str(base), *[str(p) for p in parts])
47
+ normed = os.path.normpath(joined)
48
+ base_str = os.path.normpath(str(base))
49
+ if normed != base_str and not normed.startswith(base_str + os.sep):
50
+ raise WorkspaceError(f"Path traversal detected in: {parts!r}")
51
+ return Path(normed)
52
+
53
+
54
+ def workspace_paths(workspace: Path) -> dict[str, Path]:
55
+ return {
56
+ "root": workspace,
57
+ "projects": workspace / "projects",
58
+ "tasks": workspace / "tasks",
59
+ "milestones": workspace / "milestones",
60
+ "assets": workspace / "assets",
61
+ }
62
+
63
+
64
+ def ensure_workspace(workspace: Path) -> None:
65
+ paths = workspace_paths(workspace)
66
+ paths["root"].mkdir(parents=True, exist_ok=True)
67
+ paths["projects"].mkdir(parents=True, exist_ok=True)
68
+ paths["tasks"].mkdir(parents=True, exist_ok=True)
69
+ paths["milestones"].mkdir(parents=True, exist_ok=True)
70
+ paths["assets"].mkdir(parents=True, exist_ok=True)
71
+ config = workspace / "config.json"
72
+ if not config.exists():
73
+ save_json(config, default_workspace_config(workspace))
74
+
75
+
76
+ def init_workspace(workspace: Path, with_sample: bool = True) -> None:
77
+ ensure_workspace(workspace)
78
+ return
79
+
80
+
81
+ def load_json(path: Path) -> dict[str, Any]:
82
+ try:
83
+ return json.loads(path.read_text(encoding="utf-8"))
84
+ except FileNotFoundError as exc:
85
+ raise WorkspaceError(f"Missing file: {path}") from exc
86
+ except json.JSONDecodeError as exc:
87
+ raise WorkspaceError(f"Invalid JSON in {path}: {exc}") from exc
88
+
89
+
90
+ def save_json(path: Path, data: dict[str, Any]) -> None:
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
93
+
94
+
95
+ def _slugify_name(name: str) -> str:
96
+ slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
97
+ return slug or "default"
98
+
99
+
100
+ def _project_filename(name: str) -> str:
101
+ return f"{_slugify_name(name)}.json"
102
+
103
+
104
+ def load_project(workspace: Path, name: str) -> Project:
105
+ return Project.model_validate(load_json(workspace / "projects" / _project_filename(name)))
106
+
107
+
108
+ def load_all_projects(workspace: Path) -> list[Project]:
109
+ ensure_workspace(workspace)
110
+ projects_dir = workspace / "projects"
111
+ projects = [Project.model_validate(load_json(path)) for path in sorted(projects_dir.glob("*.json"), key=lambda p: p.name.lower())]
112
+ return projects
113
+
114
+
115
+ def save_project(workspace: Path, project: Project) -> None:
116
+ ensure_workspace(workspace)
117
+ save_json(workspace / "projects" / _project_filename(project.name), project.model_dump(mode="json"))
118
+
119
+
120
+ DEFAULT_PROJECT_COLOR = "#2e6fd8"
121
+ PROJECT_PALETTE = [
122
+ "#2e6fd8",
123
+ "#338a52",
124
+ "#c05746",
125
+ "#8a5cd1",
126
+ "#c08a2e",
127
+ "#2e9bb0",
128
+ "#b0457f",
129
+ "#5c7a8a",
130
+ ]
131
+
132
+
133
+ def available_projects(projects: list[Project], tasks: list[Task]) -> list[Project]:
134
+ by_name: dict[str, Project] = {p.name: p for p in projects}
135
+ for task in tasks:
136
+ if task.project and task.project not in by_name:
137
+ by_name[task.project] = Project(name=task.project, color=DEFAULT_PROJECT_COLOR)
138
+ return sorted(by_name.values(), key=lambda p: p.name.lower())
139
+
140
+
141
+ def project_colors(projects: list[Project], tasks: list[Task]) -> dict[str, str]:
142
+ return {p.name: p.color for p in available_projects(projects, tasks)}
143
+
144
+
145
+ def register_project(workspace: Path, name: str) -> None:
146
+ name = (name or "").strip()
147
+ if not name:
148
+ return
149
+ projects = load_all_projects(workspace)
150
+ if not any(p.name == name for p in projects):
151
+ used = {p.color for p in projects}
152
+ color = next((c for c in PROJECT_PALETTE if c not in used), DEFAULT_PROJECT_COLOR)
153
+ save_project(workspace, Project(name=name, color=color))
154
+
155
+
156
+ def upsert_project(workspace: Path, name: str, description: str = "", color: str = "") -> None:
157
+ name = (name or "").strip()
158
+ if not name:
159
+ return
160
+ color = (color or "").strip() or DEFAULT_PROJECT_COLOR
161
+ project = next((item for item in load_all_projects(workspace) if item.name == name), None)
162
+ if project is None:
163
+ project = Project(name=name)
164
+ project.description = description
165
+ project.color = color
166
+ save_project(workspace, project)
167
+
168
+
169
+ def workspace_label(workspace: Path) -> str:
170
+ config_name = (load_workspace_config(workspace).get("workspace_name") or "").strip()
171
+ if config_name:
172
+ return config_name
173
+ return _workspace_label_from_path(workspace)
174
+
175
+
176
+ def _workspace_label_from_path(workspace: Path) -> str:
177
+ label = (workspace.name or DEFAULT_WORKSPACE_APP_NAME).replace("_", " ").replace("-", " ").strip()
178
+ return label.title() if label else DEFAULT_WORKSPACE_APP_NAME
179
+
180
+
181
+ def default_workspace_config(workspace: Path) -> dict[str, str]:
182
+ workspace_name = _workspace_label_from_path(workspace)
183
+ return {
184
+ "app_name": DEFAULT_WORKSPACE_APP_NAME,
185
+ "workspace_name": workspace_name,
186
+ "workspace_description": DEFAULT_WORKSPACE_DESCRIPTION,
187
+ "export_title": workspace_name,
188
+ }
189
+
190
+
191
+ def load_workspace_config(workspace: Path) -> dict[str, str]:
192
+ ensure_workspace(workspace)
193
+ defaults = default_workspace_config(workspace)
194
+ raw = load_json(workspace / "config.json")
195
+ config: dict[str, str] = {}
196
+ for key, fallback in defaults.items():
197
+ value = raw.get(key)
198
+ stripped = value.strip() if isinstance(value, str) else ""
199
+ config[key] = stripped or fallback
200
+ return config
201
+
202
+
203
+ def load_task(workspace: Path, task_id: str) -> Task:
204
+ return Task.model_validate(load_json(_safe_subpath(workspace / "tasks", f"{task_id}.json")))
205
+
206
+
207
+ def load_all_tasks(workspace: Path) -> list[Task]:
208
+ ensure_workspace(workspace)
209
+ tasks: list[Task] = []
210
+ for path in sorted((workspace / "tasks").glob("*.json")):
211
+ tasks.append(Task.model_validate(load_json(path)))
212
+ return tasks
213
+
214
+
215
+ def save_task(workspace: Path, task: Task) -> None:
216
+ ensure_workspace(workspace)
217
+ save_json(_safe_subpath(workspace / "tasks", f"{task.id}.json"), task.model_dump(mode="json"))
218
+
219
+
220
+ def _generate_task_id() -> str:
221
+ raw = secrets.token_hex(8) # 16 hex characters
222
+ return "-".join(raw[i : i + 4] for i in range(0, 16, 4)).upper()
223
+
224
+
225
+ def next_task_id(workspace: Path) -> str:
226
+ tasks_dir = workspace / "tasks"
227
+ for _ in range(1000):
228
+ candidate = _generate_task_id()
229
+ if not (tasks_dir / f"{candidate}.json").exists():
230
+ return candidate
231
+ raise WorkspaceError("Unable to generate a unique task id")
232
+
233
+
234
+ def create_task(workspace: Path, title: str = "New task") -> Task:
235
+ task = Task(id=next_task_id(workspace), title=title or "New task")
236
+ save_task(workspace, task)
237
+ return task
238
+
239
+
240
+ def delete_task(workspace: Path, task_id: str) -> None:
241
+ path = _safe_subpath(workspace / "tasks", f"{task_id}.json")
242
+ if path.exists():
243
+ path.unlink()
244
+
245
+
246
+ def add_note(workspace: Path, task_id: str, body: str) -> Task:
247
+ task = load_task(workspace, task_id)
248
+ if body.strip():
249
+ task.notes.append(Note(body=body.strip()))
250
+ save_task(workspace, task)
251
+ return task
252
+
253
+
254
+ def add_attachment(workspace: Path, task_id: str, filename: str, content: bytes, content_type: str | None = None, description: str = "") -> Task:
255
+ task = load_task(workspace, task_id)
256
+ safe_name = Path(filename).name # cross-platform: strips any leading path component
257
+ target_dir = _safe_subpath(workspace / "assets", task_id)
258
+ target_dir.mkdir(parents=True, exist_ok=True)
259
+ target_path = _safe_subpath(target_dir, safe_name)
260
+ target_path.write_bytes(content)
261
+ kind = "image" if (content_type or "").startswith("image/") else "file"
262
+ rel = os.path.relpath(str(target_path), str(workspace))
263
+ if rel.startswith(".."):
264
+ raise WorkspaceError(f"Attachment path escapes workspace: {rel!r}")
265
+ task.attachments.append(
266
+ Attachment(filename=safe_name, path=rel, kind=kind, description=description.strip())
267
+ )
268
+ save_task(workspace, task)
269
+ return task
270
+
271
+
272
+ def add_task_activity_note(workspace: Path, task_id: str, body: str) -> Task:
273
+ task = load_task(workspace, task_id)
274
+ if body.strip():
275
+ task.activity.append(TaskActivityEvent(event_type="note", note_text=body.strip()))
276
+ save_task(workspace, task)
277
+ return task
278
+
279
+
280
+ def add_task_activity_image(
281
+ workspace: Path,
282
+ task_id: str,
283
+ filename: str,
284
+ content: bytes,
285
+ content_type: str | None = None,
286
+ description: str = "",
287
+ ) -> Task:
288
+ task = load_task(workspace, task_id)
289
+ safe_name = Path(filename).name # cross-platform: strips any leading path component
290
+ target_dir = _safe_subpath(workspace / "assets", task_id)
291
+ target_dir.mkdir(parents=True, exist_ok=True)
292
+ target_path = _safe_subpath(target_dir, safe_name)
293
+ target_path.write_bytes(content)
294
+ rel = os.path.relpath(str(target_path), str(workspace))
295
+ if rel.startswith(".."):
296
+ raise WorkspaceError(f"Image path escapes workspace: {rel!r}")
297
+ description_text = description.strip() or None
298
+ task.activity.append(
299
+ TaskActivityEvent(
300
+ event_type="image",
301
+ image_path=rel,
302
+ image_filename=safe_name,
303
+ note_text=description_text,
304
+ )
305
+ )
306
+ save_task(workspace, task)
307
+ return task
308
+
309
+
310
+ def log_progress_change(workspace_path: Path, task: Task, old_progress: int, new_progress: int) -> None:
311
+ if old_progress != new_progress:
312
+ task.activity.append(
313
+ TaskActivityEvent(
314
+ event_type="progress_update",
315
+ progress_before=old_progress,
316
+ progress_after=new_progress,
317
+ )
318
+ )
319
+
320
+
321
+ # --- Milestones -------------------------------------------------------------
322
+
323
+
324
+ def load_milestone(workspace: Path, milestone_id: str) -> Milestone:
325
+ return Milestone.model_validate(load_json(_safe_subpath(workspace / "milestones", f"{milestone_id}.json")))
326
+
327
+
328
+ def load_all_milestones(workspace: Path) -> list[Milestone]:
329
+ ensure_workspace(workspace)
330
+ milestones: list[Milestone] = []
331
+ for path in sorted((workspace / "milestones").glob("*.json")):
332
+ milestones.append(Milestone.model_validate(load_json(path)))
333
+ return milestones
334
+
335
+
336
+ def save_milestone(workspace: Path, milestone: Milestone) -> None:
337
+ ensure_workspace(workspace)
338
+ save_json(_safe_subpath(workspace / "milestones", f"{milestone.id}.json"), milestone.model_dump(mode="json"))
339
+
340
+
341
+ def next_milestone_id(workspace: Path) -> str:
342
+ milestones_dir = workspace / "milestones"
343
+ for _ in range(1000):
344
+ candidate = "M-" + "-".join(
345
+ secrets.token_hex(4)[i : i + 4] for i in range(0, 8, 4)
346
+ ).upper()
347
+ if not (milestones_dir / f"{candidate}.json").exists():
348
+ return candidate
349
+ raise WorkspaceError("Unable to generate a unique milestone id")
350
+
351
+
352
+ def create_milestone(workspace: Path, title: str = "New milestone") -> Milestone:
353
+ milestone = Milestone(id=next_milestone_id(workspace), title=title or "New milestone")
354
+ save_milestone(workspace, milestone)
355
+ return milestone
356
+
357
+
358
+ def delete_milestone(workspace: Path, milestone_id: str) -> None:
359
+ path = _safe_subpath(workspace / "milestones", f"{milestone_id}.json")
360
+ if path.exists():
361
+ path.unlink()
362
+ assets = _safe_subpath(workspace / "assets", milestone_id)
363
+ if assets.exists():
364
+ shutil.rmtree(assets, ignore_errors=True)
365
+
366
+
367
+ def add_milestone_note(workspace: Path, milestone_id: str, body: str) -> Milestone:
368
+ milestone = load_milestone(workspace, milestone_id)
369
+ if body.strip():
370
+ milestone.notes.append(Note(body=body.strip()))
371
+ save_milestone(workspace, milestone)
372
+ return milestone
373
+
374
+
375
+ def add_milestone_attachment(
376
+ workspace: Path,
377
+ milestone_id: str,
378
+ filename: str,
379
+ content: bytes,
380
+ content_type: str | None = None,
381
+ description: str = "",
382
+ ) -> Milestone:
383
+ milestone = load_milestone(workspace, milestone_id)
384
+ safe_name = Path(filename).name # cross-platform: strips any leading path component
385
+ target_dir = _safe_subpath(workspace / "assets", milestone_id)
386
+ target_dir.mkdir(parents=True, exist_ok=True)
387
+ target_path = _safe_subpath(target_dir, safe_name)
388
+ target_path.write_bytes(content)
389
+ rel = os.path.relpath(str(target_path), str(workspace))
390
+ if rel.startswith(".."):
391
+ raise WorkspaceError(f"Attachment path escapes workspace: {rel!r}")
392
+ kind = "image" if (content_type or "").startswith("image/") else "file"
393
+ milestone.attachments.append(
394
+ Attachment(
395
+ filename=safe_name,
396
+ path=rel,
397
+ kind=kind,
398
+ description=description.strip(),
399
+ )
400
+ )
401
+ save_milestone(workspace, milestone)
402
+ return milestone
403
+
404
+
405
+ def add_task_to_milestone(workspace: Path, milestone_id: str, task_id: str) -> Milestone:
406
+ milestone = load_milestone(workspace, milestone_id)
407
+ if task_id and task_id not in milestone.task_ids:
408
+ if _safe_subpath(workspace / "tasks", f"{task_id}.json").exists():
409
+ milestone.task_ids.append(task_id)
410
+ save_milestone(workspace, milestone)
411
+ return milestone
412
+
413
+
414
+ def remove_task_from_milestone(workspace: Path, milestone_id: str, task_id: str) -> Milestone:
415
+ milestone = load_milestone(workspace, milestone_id)
416
+ if task_id in milestone.task_ids:
417
+ milestone.task_ids.remove(task_id)
418
+ save_milestone(workspace, milestone)
419
+ return milestone
420
+
421
+
422
+ def move_task_in_milestone(workspace: Path, milestone_id: str, task_id: str, direction: str) -> Milestone:
423
+ milestone = load_milestone(workspace, milestone_id)
424
+ ids = milestone.task_ids
425
+ if task_id in ids:
426
+ idx = ids.index(task_id)
427
+ swap = idx - 1 if direction == "up" else idx + 1
428
+ if 0 <= swap < len(ids):
429
+ ids[idx], ids[swap] = ids[swap], ids[idx]
430
+ save_milestone(workspace, milestone)
431
+ return milestone
432
+
433
+
434
+ def copy_starter_files(target: Path) -> None:
435
+ init_workspace(target, with_sample=False)
436
+ readme = target / "README.md"
437
+ if not readme.exists():
438
+ readme.write_text(
439
+ "# My Taskunity Workspace\n\n"
440
+ "Run `taskunity serve` from this folder to launch the local dashboard.\n",
441
+ encoding="utf-8",
442
+ )
443
+
444
+
445
+ def _git(workspace: Path, *args: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
446
+ return subprocess.run(
447
+ ["git", *args],
448
+ cwd=str(workspace),
449
+ capture_output=True,
450
+ text=True,
451
+ timeout=timeout,
452
+ )
453
+
454
+
455
+ def _workspace_repo_message() -> str:
456
+ return "Git integration only works when the workspace folder is the repository root."
457
+
458
+
459
+ def git_status(workspace: Path) -> dict[str, Any]:
460
+ info: dict[str, Any] = {
461
+ "tracked": False,
462
+ "branch": "",
463
+ "upstream": None,
464
+ "ahead": 0,
465
+ "behind": 0,
466
+ "dirty": 0,
467
+ "message": "",
468
+ }
469
+ try:
470
+ inside = _git(workspace, "rev-parse", "--is-inside-work-tree")
471
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
472
+ return info
473
+ top_level = _git(workspace, "rev-parse", "--show-toplevel")
474
+ if top_level.returncode != 0:
475
+ return info
476
+ if Path(top_level.stdout.strip()).resolve() != workspace.resolve():
477
+ info["message"] = _workspace_repo_message()
478
+ return info
479
+ info["tracked"] = True
480
+ info["branch"] = _git(workspace, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
481
+ upstream = _git(workspace, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
482
+ if upstream.returncode == 0:
483
+ info["upstream"] = upstream.stdout.strip()
484
+ counts = _git(workspace, "rev-list", "--left-right", "--count", "@{u}...HEAD")
485
+ if counts.returncode == 0:
486
+ parts = counts.stdout.split()
487
+ if len(parts) == 2:
488
+ info["behind"] = int(parts[0])
489
+ info["ahead"] = int(parts[1])
490
+ status = _git(workspace, "status", "--porcelain", "--", ".")
491
+ if status.returncode == 0:
492
+ info["dirty"] = len([line for line in status.stdout.splitlines() if line.strip()])
493
+ except (OSError, subprocess.SubprocessError) as exc:
494
+ info["message"] = str(exc)
495
+ return info
496
+
497
+
498
+ def git_sync(workspace: Path) -> dict[str, Any]:
499
+ result: dict[str, Any] = {"ok": False, "message": ""}
500
+ status = git_status(workspace)
501
+ if not status["tracked"]:
502
+ result["message"] = status["message"] or "This workspace is not inside a git repository."
503
+ return result
504
+ try:
505
+ if status["dirty"]:
506
+ _git(workspace, "add", "-A", "--", ".")
507
+ commit = _git(
508
+ workspace, "commit", "-m", f"taskunity: sync workspace ({datetime.now():%Y-%m-%d %H:%M})"
509
+ )
510
+ if commit.returncode != 0 and "nothing to commit" not in (commit.stdout + commit.stderr).lower():
511
+ result["message"] = "Commit failed: " + (commit.stderr.strip() or commit.stdout.strip())
512
+ return result
513
+ if not status["upstream"]:
514
+ result["ok"] = True
515
+ result["message"] = "Committed locally. No upstream is configured, so nothing was pushed."
516
+ return result
517
+ pull = _git(workspace, "pull", "--no-edit")
518
+ if pull.returncode != 0:
519
+ result["message"] = "Pull failed: " + (pull.stderr.strip() or pull.stdout.strip())
520
+ return result
521
+ push = _git(workspace, "push")
522
+ if push.returncode != 0:
523
+ result["message"] = "Push failed: " + (push.stderr.strip() or push.stdout.strip())
524
+ return result
525
+ result["ok"] = True
526
+ result["message"] = f"Synced with {status['upstream']}."
527
+ except (OSError, subprocess.SubprocessError) as exc:
528
+ result["message"] = str(exc)
529
+ return result
530
+
531
+
532
+ def git_lfs_available(workspace: Path) -> bool:
533
+ """Return True if git-lfs is installed and accessible."""
534
+ try:
535
+ result = subprocess.run(
536
+ ["git", "lfs", "version"],
537
+ capture_output=True,
538
+ text=True,
539
+ timeout=5,
540
+ )
541
+ return result.returncode == 0
542
+ except (OSError, subprocess.SubprocessError):
543
+ return False
544
+
545
+
546
+ def git_lfs_init(workspace: Path) -> dict[str, Any]:
547
+ """Initialize git-lfs in the workspace: run `git lfs install` and track assets."""
548
+ result: dict[str, Any] = {"ok": False, "message": ""}
549
+ if not git_lfs_available(workspace):
550
+ result["message"] = "git-lfs is not installed or not on PATH."
551
+ return result
552
+ status = git_status(workspace)
553
+ if not status["tracked"]:
554
+ result["message"] = status["message"] or "Workspace is not a git repository."
555
+ return result
556
+ try:
557
+ install = _git(workspace, "lfs", "install", "--local")
558
+ if install.returncode != 0:
559
+ result["message"] = "git lfs install failed: " + (install.stderr.strip() or install.stdout.strip())
560
+ return result
561
+ track = _git(workspace, "lfs", "track", "assets/**")
562
+ if track.returncode != 0:
563
+ result["message"] = "git lfs track failed: " + (track.stderr.strip() or track.stdout.strip())
564
+ return result
565
+ add = _git(workspace, "add", ".gitattributes")
566
+ if add.returncode != 0:
567
+ result["message"] = "git add .gitattributes failed"
568
+ return result
569
+ commit = _git(workspace, "commit", "-m", "chore: initialize git-lfs tracking for assets")
570
+ if commit.returncode != 0 and "nothing to commit" not in (commit.stdout + commit.stderr).lower():
571
+ result["message"] = "Commit failed: " + (commit.stderr.strip() or commit.stdout.strip())
572
+ return result
573
+ result["ok"] = True
574
+ result["message"] = "git-lfs initialized. Assets directory is now tracked with LFS."
575
+ except (OSError, subprocess.SubprocessError) as exc:
576
+ result["message"] = str(exc)
577
+ return result
578
+
579
+
580
+ def git_lfs_status(workspace: Path) -> dict[str, Any]:
581
+ """Return LFS status information for the workspace."""
582
+ info: dict[str, Any] = {"available": False, "enabled": False, "tracking_assets": False, "message": ""}
583
+ info["available"] = git_lfs_available(workspace)
584
+ if not info["available"]:
585
+ return info
586
+ status = git_status(workspace)
587
+ if not status["tracked"]:
588
+ return info
589
+ try:
590
+ lfs_hooks = workspace / ".git" / "hooks" / "pre-push"
591
+ info["enabled"] = lfs_hooks.exists()
592
+ gitattributes = workspace / ".gitattributes"
593
+ if gitattributes.exists():
594
+ content = gitattributes.read_text(encoding="utf-8", errors="replace")
595
+ info["tracking_assets"] = "assets/**" in content or "assets/" in content
596
+ except (OSError, IOError) as exc:
597
+ info["message"] = str(exc)
598
+ return info