campfire-cli 0.1.0__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.
- campfire_cli/__init__.py +3 -0
- campfire_cli/alembic/__init__.py +1 -0
- campfire_cli/alembic/env.py +37 -0
- campfire_cli/alembic/script.py.mako +22 -0
- campfire_cli/alembic/versions/001_initial_schema.py +81 -0
- campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
- campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
- campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
- campfire_cli/alembic/versions/005_decision_channel.py +71 -0
- campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
- campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
- campfire_cli/alembic/versions/__init__.py +1 -0
- campfire_cli/app/SPEC.md +12 -0
- campfire_cli/app/__init__.py +1 -0
- campfire_cli/app/base/SPEC.md +3 -0
- campfire_cli/app/base/__init__.py +1 -0
- campfire_cli/app/base/cli/base_cli.py +37 -0
- campfire_cli/app/base/repository/base_repository.py +17 -0
- campfire_cli/app/base/schema/base_schema.py +18 -0
- campfire_cli/app/base/service/base_service.py +129 -0
- campfire_cli/app/decision/SPEC.md +16 -0
- campfire_cli/app/decision/__init__.py +1 -0
- campfire_cli/app/decision/cli/decision_cli.py +112 -0
- campfire_cli/app/decision/repository/decision_repository.py +199 -0
- campfire_cli/app/decision/schema/decision_schema.py +56 -0
- campfire_cli/app/decision/service/decision_projection_service.py +151 -0
- campfire_cli/app/decision/service/decision_service.py +124 -0
- campfire_cli/app/document/SPEC.md +51 -0
- campfire_cli/app/document/__init__.py +1 -0
- campfire_cli/app/document/cli/__init__.py +1 -0
- campfire_cli/app/document/cli/document_cli.py +63 -0
- campfire_cli/app/document/cli/profile_cli.py +65 -0
- campfire_cli/app/document/cli/type_cli.py +49 -0
- campfire_cli/app/document/repository/__init__.py +1 -0
- campfire_cli/app/document/repository/document_profile_repository.py +17 -0
- campfire_cli/app/document/repository/document_type_repository.py +17 -0
- campfire_cli/app/document/service/__init__.py +1 -0
- campfire_cli/app/document/service/document_profile_service.py +66 -0
- campfire_cli/app/document/service/document_rule_service.py +316 -0
- campfire_cli/app/document/service/document_scanner.py +38 -0
- campfire_cli/app/document/service/document_service.py +139 -0
- campfire_cli/app/document/service/document_type_service.py +28 -0
- campfire_cli/app/document/service/frontmatter_apply.py +98 -0
- campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
- campfire_cli/app/document/service/frontmatter_plan.py +75 -0
- campfire_cli/app/document/service/profile_registry.py +166 -0
- campfire_cli/app/document/service/type_apply.py +185 -0
- campfire_cli/app/document/service/type_plan.py +80 -0
- campfire_cli/app/maintenance/SPEC.md +22 -0
- campfire_cli/app/maintenance/__init__.py +1 -0
- campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
- campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
- campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
- campfire_cli/app/maintenance/service/archive_service.py +255 -0
- campfire_cli/app/maintenance/service/link_service.py +91 -0
- campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
- campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
- campfire_cli/app/maintenance/service/moc_service.py +310 -0
- campfire_cli/app/maintenance/service/plan_service.py +411 -0
- campfire_cli/app/skill/SPEC.md +3 -0
- campfire_cli/app/skill/__init__.py +1 -0
- campfire_cli/app/skill/cli/skill_cli.py +44 -0
- campfire_cli/app/skill/repository/skill_repository.py +33 -0
- campfire_cli/app/skill/schema/skill_schema.py +18 -0
- campfire_cli/app/skill/service/skill_service.py +146 -0
- campfire_cli/app/workspace/SPEC.md +25 -0
- campfire_cli/app/workspace/__init__.py +1 -0
- campfire_cli/app/workspace/cli/__init__.py +1 -0
- campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
- campfire_cli/app/workspace/cli/config_cli.py +30 -0
- campfire_cli/app/workspace/cli/domain_cli.py +101 -0
- campfire_cli/app/workspace/cli/project_cli.py +135 -0
- campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
- campfire_cli/app/workspace/cli/space_cli.py +65 -0
- campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
- campfire_cli/app/workspace/repository/__init__.py +1 -0
- campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
- campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
- campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
- campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
- campfire_cli/app/workspace/schema/__init__.py +1 -0
- campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
- campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
- campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
- campfire_cli/app/workspace/service/__init__.py +1 -0
- campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
- campfire_cli/app/workspace/service/adoption_service.py +364 -0
- campfire_cli/app/workspace/service/config_service.py +189 -0
- campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
- campfire_cli/app/workspace/service/project_service.py +389 -0
- campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
- campfire_cli/app/workspace/service/restructure_service.py +392 -0
- campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
- campfire_cli/app/workspace/service/structure_service.py +494 -0
- campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
- campfire_cli/app/workspace/service/workspace_service.py +304 -0
- campfire_cli/common/SPEC.md +12 -0
- campfire_cli/common/__init__.py +1 -0
- campfire_cli/common/database/__init__.py +4 -0
- campfire_cli/common/database/migrations.py +18 -0
- campfire_cli/common/database/models.py +215 -0
- campfire_cli/common/database/session.py +30 -0
- campfire_cli/common/documents/__init__.py +1 -0
- campfire_cli/common/documents/document_types.py +119 -0
- campfire_cli/common/documents/frontmatter_schema.py +42 -0
- campfire_cli/common/documents/markdown.py +66 -0
- campfire_cli/common/exceptions/__init__.py +7 -0
- campfire_cli/common/exceptions/base.py +19 -0
- campfire_cli/common/filesystem/__init__.py +6 -0
- campfire_cli/common/filesystem/atomic.py +30 -0
- campfire_cli/common/filesystem/locking.py +55 -0
- campfire_cli/common/governance/__init__.py +13 -0
- campfire_cli/common/governance/issues.py +38 -0
- campfire_cli/common/governance/locking.py +31 -0
- campfire_cli/common/governance/snapshots.py +26 -0
- campfire_cli/common/hashing.py +10 -0
- campfire_cli/common/reports/__init__.py +1 -0
- campfire_cli/common/reports/json_report.py +8 -0
- campfire_cli/common/reports/markdown_report.py +29 -0
- campfire_cli/config/SPEC.md +5 -0
- campfire_cli/config/defaults.py +64 -0
- campfire_cli/config/settings.py +50 -0
- campfire_cli/container.py +130 -0
- campfire_cli/main.py +133 -0
- campfire_cli/resources/__init__.py +1 -0
- campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
- campfire_cli/resources/bases/SPEC.md +3 -0
- campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
- campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
- campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
- campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
- campfire_cli/resources/defaults/__init__.py +1 -0
- campfire_cli/resources/defaults/config.yml +458 -0
- campfire_cli/resources/skills/SPEC.md +3 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
- campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
- campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
- campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/references//344/273/273/345/212/241/346/255/243/346/226/207/347/273/223/346/236/204.md +23 -0
- campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
- campfire_cli-0.1.0.dist-info/METADATA +141 -0
- campfire_cli-0.1.0.dist-info/RECORD +154 -0
- campfire_cli-0.1.0.dist-info/WHEEL +4 -0
- campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
- campfire_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from campfire_cli.app.workspace.repository.manifest_repository import WorkspaceManifestRepository
|
|
8
|
+
from campfire_cli.app.workspace.schema.workspace_schema import (
|
|
9
|
+
ManifestProject,
|
|
10
|
+
ProjectBindResult,
|
|
11
|
+
ProjectCheckResult,
|
|
12
|
+
ProjectCreateResult,
|
|
13
|
+
ProjectEntry,
|
|
14
|
+
ProjectListResult,
|
|
15
|
+
ProjectMatch,
|
|
16
|
+
ProjectResolutionResult,
|
|
17
|
+
ProjectResult,
|
|
18
|
+
ProjectUpsertRequest,
|
|
19
|
+
)
|
|
20
|
+
from campfire_cli.app.workspace.service.structure_service import DomainService, SpaceService
|
|
21
|
+
from campfire_cli.app.workspace.service.workspace_protocol import WorkspaceRepositoryProtocol
|
|
22
|
+
from campfire_cli.common.exceptions import ConfigurationError
|
|
23
|
+
from campfire_cli.common.filesystem import workspace_write_lock
|
|
24
|
+
from campfire_cli.config.defaults import config_section
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProjectService:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
governance_root: Path,
|
|
31
|
+
repository: WorkspaceRepositoryProtocol,
|
|
32
|
+
manifest_repository: WorkspaceManifestRepository | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._root = governance_root
|
|
35
|
+
self._repository = repository
|
|
36
|
+
self._manifests = manifest_repository or WorkspaceManifestRepository()
|
|
37
|
+
|
|
38
|
+
def add(self, request: ProjectUpsertRequest) -> ProjectResult:
|
|
39
|
+
if self._repository.get_project(request.project_id):
|
|
40
|
+
raise ConfigurationError(
|
|
41
|
+
f"Project 已存在:{request.project_id};请使用 campfire workspace project update"
|
|
42
|
+
)
|
|
43
|
+
return self._save(request)
|
|
44
|
+
|
|
45
|
+
def update(self, request: ProjectUpsertRequest) -> ProjectResult:
|
|
46
|
+
if not self._repository.get_project(request.project_id):
|
|
47
|
+
raise ConfigurationError(
|
|
48
|
+
f"Project 未注册:{request.project_id};请使用 campfire workspace project add"
|
|
49
|
+
)
|
|
50
|
+
return self._save(request)
|
|
51
|
+
|
|
52
|
+
def create(self, request: ProjectUpsertRequest, confirm: bool = False) -> ProjectCreateResult:
|
|
53
|
+
if self._repository.get_project(request.project_id):
|
|
54
|
+
raise ConfigurationError(f"Project 已存在:{request.project_id}")
|
|
55
|
+
project, domain_path = self._prepare(request, require_domain=False)
|
|
56
|
+
if domain_path.exists():
|
|
57
|
+
raise ConfigurationError(
|
|
58
|
+
f"项目文档领域已存在;接入现有领域请使用 project add:{domain_path}"
|
|
59
|
+
)
|
|
60
|
+
structure = DomainService(
|
|
61
|
+
Path(self._repository.load_registry().workspaces[project.workspace_id].path), self._root
|
|
62
|
+
)
|
|
63
|
+
space_id = self._owning_space_id(structure.spaces, project.document_domain)
|
|
64
|
+
domain_plan = structure.create(
|
|
65
|
+
domain_id=f"project-{project.id}",
|
|
66
|
+
name=project.name,
|
|
67
|
+
path=project.document_domain,
|
|
68
|
+
space_id=space_id,
|
|
69
|
+
domain_type="project-domain",
|
|
70
|
+
governance="project-docs",
|
|
71
|
+
project_id=project.id,
|
|
72
|
+
confirm=False,
|
|
73
|
+
)
|
|
74
|
+
operations = [*domain_plan.operations, {"action": "register-project", "path": project.id}]
|
|
75
|
+
if not confirm:
|
|
76
|
+
return ProjectCreateResult(
|
|
77
|
+
status="planned",
|
|
78
|
+
project=project,
|
|
79
|
+
document_domain_path=str(domain_path),
|
|
80
|
+
operations=operations,
|
|
81
|
+
)
|
|
82
|
+
structure.create(
|
|
83
|
+
domain_id=f"project-{project.id}",
|
|
84
|
+
name=project.name,
|
|
85
|
+
path=project.document_domain,
|
|
86
|
+
space_id=space_id,
|
|
87
|
+
domain_type="project-domain",
|
|
88
|
+
governance="project-docs",
|
|
89
|
+
project_id=project.id,
|
|
90
|
+
confirm=True,
|
|
91
|
+
)
|
|
92
|
+
with workspace_write_lock(self._root):
|
|
93
|
+
if self._repository.get_project(request.project_id):
|
|
94
|
+
raise ConfigurationError("Project 在确认后已被注册")
|
|
95
|
+
self._repository.save_project(project)
|
|
96
|
+
self._sync_manifest(project.workspace_id)
|
|
97
|
+
return ProjectCreateResult(
|
|
98
|
+
status="created",
|
|
99
|
+
project=project,
|
|
100
|
+
document_domain_path=str(domain_path),
|
|
101
|
+
operations=operations,
|
|
102
|
+
write_performed=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def list(self, workspace_id: str | None = None) -> ProjectListResult:
|
|
106
|
+
if workspace_id and workspace_id not in self._repository.load_registry().workspaces:
|
|
107
|
+
raise ConfigurationError(f"Workspace 未注册:{workspace_id}")
|
|
108
|
+
return ProjectListResult(projects=self._repository.list_projects(workspace_id))
|
|
109
|
+
|
|
110
|
+
def show(self, project_id: str) -> ProjectResult:
|
|
111
|
+
project = self._repository.get_project(project_id)
|
|
112
|
+
if not project:
|
|
113
|
+
raise ConfigurationError(f"Project 未注册:{project_id}")
|
|
114
|
+
return ProjectResult(**project.model_dump(), operation="none")
|
|
115
|
+
|
|
116
|
+
def resolve(self, path: Path) -> ProjectResolutionResult:
|
|
117
|
+
query = path.expanduser().resolve()
|
|
118
|
+
git_root_value = self._git_command(query, "rev-parse", "--show-toplevel")
|
|
119
|
+
git_root = Path(git_root_value).resolve() if git_root_value else None
|
|
120
|
+
remote = self._git_command(git_root or query, "remote", "get-url", "origin")
|
|
121
|
+
normalized_remote = self._normalize_remote(remote)
|
|
122
|
+
matches: list[ProjectMatch] = []
|
|
123
|
+
for project in self._repository.list_projects():
|
|
124
|
+
basis: list[str] = []
|
|
125
|
+
if project.local_path:
|
|
126
|
+
registered = Path(project.local_path).expanduser().resolve()
|
|
127
|
+
if query == registered or registered in query.parents:
|
|
128
|
+
basis.append("local-path")
|
|
129
|
+
if (
|
|
130
|
+
normalized_remote
|
|
131
|
+
and project.git_remote_url
|
|
132
|
+
and normalized_remote == self._normalize_remote(project.git_remote_url)
|
|
133
|
+
):
|
|
134
|
+
basis.append("git-remote")
|
|
135
|
+
if basis:
|
|
136
|
+
matches.append(ProjectMatch(project=project, match_basis=basis))
|
|
137
|
+
local_matches = [item for item in matches if "local-path" in item.match_basis]
|
|
138
|
+
remote_only = [item for item in matches if "local-path" not in item.match_basis]
|
|
139
|
+
if local_matches:
|
|
140
|
+
deepest = max(len(Path(item.project.local_path or "/").parts) for item in local_matches)
|
|
141
|
+
local_matches = [
|
|
142
|
+
item
|
|
143
|
+
for item in local_matches
|
|
144
|
+
if len(Path(item.project.local_path or "/").parts) == deepest
|
|
145
|
+
]
|
|
146
|
+
status = "matched" if len(local_matches) == 1 else "ambiguous"
|
|
147
|
+
return ProjectResolutionResult(
|
|
148
|
+
status=status,
|
|
149
|
+
query_path=str(query),
|
|
150
|
+
git_root=str(git_root) if git_root else None,
|
|
151
|
+
git_remote_url=remote,
|
|
152
|
+
matches=local_matches,
|
|
153
|
+
)
|
|
154
|
+
if remote_only:
|
|
155
|
+
# 查询目录本身未注册(常见于 monorepo 子目录或未绑定 local_path 的新机器),
|
|
156
|
+
# 仅共享 Git remote 不足以断言"当前目录属于该项目",不返回 matched 终态。
|
|
157
|
+
candidates = "、".join(item.project.id for item in remote_only)
|
|
158
|
+
return ProjectResolutionResult(
|
|
159
|
+
status="unmatched",
|
|
160
|
+
query_path=str(query),
|
|
161
|
+
git_root=str(git_root) if git_root else None,
|
|
162
|
+
git_remote_url=remote,
|
|
163
|
+
remote_matches=remote_only,
|
|
164
|
+
hint=(
|
|
165
|
+
f"查询目录未注册为任何项目的 local_path,仅与 {candidates} 共享 Git remote;"
|
|
166
|
+
"若确属其中之一请运行 campfire workspace project bind "
|
|
167
|
+
"--id <id> --local-path <path>,若是新项目(如 monorepo 子目录)请走注册流程"
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
return ProjectResolutionResult(
|
|
171
|
+
status="unmatched",
|
|
172
|
+
query_path=str(query),
|
|
173
|
+
git_root=str(git_root) if git_root else None,
|
|
174
|
+
git_remote_url=remote,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def check(self, project_id: str) -> ProjectCheckResult:
|
|
178
|
+
project = self._repository.get_project(project_id)
|
|
179
|
+
if not project:
|
|
180
|
+
raise ConfigurationError(f"Project 未注册:{project_id}")
|
|
181
|
+
registry = self._repository.load_registry()
|
|
182
|
+
workspace = registry.workspaces.get(project.workspace_id)
|
|
183
|
+
issues: list[dict[str, str | None]] = []
|
|
184
|
+
local_path = Path(project.local_path).expanduser().resolve() if project.local_path else None
|
|
185
|
+
local_exists = bool(local_path and local_path.is_dir())
|
|
186
|
+
if project.local_path and not local_exists:
|
|
187
|
+
issues.append({"code": "project-local-path-missing", "path": project.local_path})
|
|
188
|
+
observed_remote = (
|
|
189
|
+
self._git_command(local_path, "remote", "get-url", "origin") if local_exists else None
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
observed_remote
|
|
194
|
+
and project.git_remote_url
|
|
195
|
+
and self._normalize_remote(observed_remote)
|
|
196
|
+
!= self._normalize_remote(project.git_remote_url)
|
|
197
|
+
):
|
|
198
|
+
issues.append(
|
|
199
|
+
{
|
|
200
|
+
"code": "project-git-remote-changed",
|
|
201
|
+
"expected": project.git_remote_url,
|
|
202
|
+
"actual": observed_remote,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
observed_branch = self._detect_default_branch(local_path) if local_exists else None
|
|
206
|
+
if observed_branch and project.default_branch and observed_branch != project.default_branch:
|
|
207
|
+
issues.append(
|
|
208
|
+
{
|
|
209
|
+
"code": "project-default-branch-changed",
|
|
210
|
+
"expected": project.default_branch,
|
|
211
|
+
"actual": observed_branch,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
domain_path = (
|
|
215
|
+
Path(workspace.path) / project.document_domain if workspace is not None else None
|
|
216
|
+
)
|
|
217
|
+
if workspace is None:
|
|
218
|
+
issues.append({"code": "project-workspace-missing", "path": project.workspace_id})
|
|
219
|
+
elif not domain_path.is_dir():
|
|
220
|
+
issues.append({"code": "project-document-domain-missing", "path": str(domain_path)})
|
|
221
|
+
return ProjectCheckResult(
|
|
222
|
+
status="ok" if not issues else "needs-review",
|
|
223
|
+
project=project,
|
|
224
|
+
observed={
|
|
225
|
+
"local_path_exists": local_exists,
|
|
226
|
+
"git_remote_url": observed_remote,
|
|
227
|
+
"default_branch": observed_branch,
|
|
228
|
+
"document_domain_path": str(domain_path) if domain_path else None,
|
|
229
|
+
"document_domain_exists": bool(domain_path and domain_path.is_dir()),
|
|
230
|
+
},
|
|
231
|
+
issues=issues,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def bind(self, project_id: str, local_path: Path) -> ProjectBindResult:
|
|
235
|
+
project = self._repository.get_project(project_id)
|
|
236
|
+
if not project:
|
|
237
|
+
raise ConfigurationError(f"Project 未注册:{project_id}")
|
|
238
|
+
path = local_path.expanduser().resolve()
|
|
239
|
+
if not path.is_dir():
|
|
240
|
+
raise ConfigurationError(f"项目本地路径不存在:{path}")
|
|
241
|
+
observed_remote = self._git_value(path, "remote", "get-url", "origin")
|
|
242
|
+
if (
|
|
243
|
+
project.git_remote_url
|
|
244
|
+
and observed_remote
|
|
245
|
+
and self._normalize_remote(project.git_remote_url)
|
|
246
|
+
!= self._normalize_remote(observed_remote)
|
|
247
|
+
):
|
|
248
|
+
raise ConfigurationError("本地项目的 Git remote 与 Manifest 元数据不一致")
|
|
249
|
+
updated = project.model_copy(
|
|
250
|
+
update={
|
|
251
|
+
"local_path": str(path),
|
|
252
|
+
"git_remote_url": project.git_remote_url or observed_remote,
|
|
253
|
+
"default_branch": project.default_branch or self._detect_default_branch(path),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
with workspace_write_lock(self._root):
|
|
257
|
+
self._repository.save_project(updated)
|
|
258
|
+
self._sync_manifest(updated.workspace_id)
|
|
259
|
+
return ProjectBindResult(project=updated)
|
|
260
|
+
|
|
261
|
+
def _save(self, request: ProjectUpsertRequest) -> ProjectResult:
|
|
262
|
+
project, _domain_path = self._prepare(request, require_domain=True)
|
|
263
|
+
with workspace_write_lock(self._root):
|
|
264
|
+
operation = self._repository.save_project(project)
|
|
265
|
+
self._sync_manifest(project.workspace_id)
|
|
266
|
+
return ProjectResult(**project.model_dump(), operation=operation)
|
|
267
|
+
|
|
268
|
+
def _sync_manifest(self, workspace_id: str) -> None:
|
|
269
|
+
registry = self._repository.load_registry()
|
|
270
|
+
workspace = registry.workspaces.get(workspace_id)
|
|
271
|
+
if not workspace:
|
|
272
|
+
raise ConfigurationError(f"Workspace 未注册:{workspace_id}")
|
|
273
|
+
root = Path(workspace.path).expanduser().resolve()
|
|
274
|
+
manifest = self._manifests.load(root)
|
|
275
|
+
if manifest is None:
|
|
276
|
+
raise ConfigurationError(
|
|
277
|
+
f"Workspace 缺少 .campfire.yaml;请先运行 campfire setup --workspace {root}"
|
|
278
|
+
)
|
|
279
|
+
manifest.projects = [
|
|
280
|
+
ManifestProject.model_validate(
|
|
281
|
+
project.model_dump(exclude={"workspace_id", "local_path"})
|
|
282
|
+
)
|
|
283
|
+
for project in self._repository.list_projects(workspace_id)
|
|
284
|
+
]
|
|
285
|
+
self._manifests.save(root, manifest)
|
|
286
|
+
|
|
287
|
+
def _prepare(
|
|
288
|
+
self, request: ProjectUpsertRequest, *, require_domain: bool
|
|
289
|
+
) -> tuple[ProjectEntry, Path]:
|
|
290
|
+
self._validate_id(request.project_id)
|
|
291
|
+
registry = self._repository.load_registry()
|
|
292
|
+
workspace = registry.workspaces.get(request.workspace_id)
|
|
293
|
+
if not workspace:
|
|
294
|
+
raise ConfigurationError(f"Workspace 未注册:{request.workspace_id}")
|
|
295
|
+
domain = self._validate_domain(request.document_domain)
|
|
296
|
+
domain_path = Path(workspace.path) / domain
|
|
297
|
+
if require_domain and not domain_path.is_dir():
|
|
298
|
+
raise ConfigurationError(f"项目文档领域不存在:{domain_path}")
|
|
299
|
+
local_path = request.local_path.expanduser().resolve() if request.local_path else None
|
|
300
|
+
if local_path and not local_path.is_dir():
|
|
301
|
+
raise ConfigurationError(f"项目本地路径不存在:{local_path}")
|
|
302
|
+
remote = request.git_remote_url or self._git_value(
|
|
303
|
+
local_path, "remote", "get-url", "origin"
|
|
304
|
+
)
|
|
305
|
+
branch = request.default_branch or self._detect_default_branch(local_path)
|
|
306
|
+
statuses = set(config_section("project")["statuses"])
|
|
307
|
+
if request.status not in statuses:
|
|
308
|
+
raise ConfigurationError(f"Project status 必须是:{', '.join(sorted(statuses))}")
|
|
309
|
+
project = ProjectEntry(
|
|
310
|
+
id=request.project_id,
|
|
311
|
+
workspace_id=request.workspace_id,
|
|
312
|
+
name=request.name.strip(),
|
|
313
|
+
document_domain=domain,
|
|
314
|
+
git_remote_url=remote,
|
|
315
|
+
local_path=str(local_path) if local_path else None,
|
|
316
|
+
default_branch=branch,
|
|
317
|
+
status=request.status,
|
|
318
|
+
)
|
|
319
|
+
if not project.name:
|
|
320
|
+
raise ConfigurationError("Project name 不能为空")
|
|
321
|
+
return project, domain_path
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _owning_space_id(spaces: SpaceService, document_domain: str) -> str:
|
|
325
|
+
path = PurePosixPath(document_domain)
|
|
326
|
+
matches = [
|
|
327
|
+
space
|
|
328
|
+
for space in spaces.discover()[0]
|
|
329
|
+
if path == PurePosixPath(space.path) or PurePosixPath(space.path) in path.parents
|
|
330
|
+
]
|
|
331
|
+
if len(matches) != 1:
|
|
332
|
+
raise ConfigurationError("项目文档领域必须唯一位于一个已声明 Space 下")
|
|
333
|
+
expected = config_section("project")["default_space_type"]
|
|
334
|
+
if matches[0].type != expected:
|
|
335
|
+
raise ConfigurationError(f"项目文档领域必须位于 {expected} 类型 Space")
|
|
336
|
+
return matches[0].id
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _validate_id(project_id: str) -> None:
|
|
340
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", project_id):
|
|
341
|
+
raise ConfigurationError("Project id 只能使用小写字母、数字和连字符")
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _validate_domain(value: str) -> str:
|
|
345
|
+
path = PurePosixPath(value)
|
|
346
|
+
if path.is_absolute() or ".." in path.parts or not value.strip():
|
|
347
|
+
raise ConfigurationError("document-domain 必须是 Workspace 内的相对路径")
|
|
348
|
+
return path.as_posix()
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _git_value(local_path: Path | None, *arguments: str) -> str | None:
|
|
352
|
+
if not local_path or not local_path.is_dir():
|
|
353
|
+
return None
|
|
354
|
+
result = subprocess.run(
|
|
355
|
+
["git", "-C", str(local_path), *arguments],
|
|
356
|
+
check=False,
|
|
357
|
+
capture_output=True,
|
|
358
|
+
text=True,
|
|
359
|
+
)
|
|
360
|
+
return result.stdout.strip() or None if result.returncode == 0 else None
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _git_command(path: Path, *arguments: str) -> str | None:
|
|
364
|
+
result = subprocess.run(
|
|
365
|
+
["git", "-C", str(path), *arguments],
|
|
366
|
+
check=False,
|
|
367
|
+
capture_output=True,
|
|
368
|
+
text=True,
|
|
369
|
+
)
|
|
370
|
+
return result.stdout.strip() or None if result.returncode == 0 else None
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _normalize_remote(value: str | None) -> str | None:
|
|
374
|
+
if not value:
|
|
375
|
+
return None
|
|
376
|
+
normalized = value.strip().removesuffix(".git").rstrip("/")
|
|
377
|
+
ssh = re.fullmatch(r"git@([^:]+):(.+)", normalized)
|
|
378
|
+
if ssh:
|
|
379
|
+
return f"{ssh.group(1).lower()}/{ssh.group(2).lower()}"
|
|
380
|
+
normalized = re.sub(r"^[a-z][a-z0-9+.-]*://", "", normalized, flags=re.I)
|
|
381
|
+
normalized = normalized.removeprefix("git@").replace(":", "/", 1)
|
|
382
|
+
return normalized.lower()
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def _detect_default_branch(cls, local_path: Path | None) -> str | None:
|
|
386
|
+
reference = cls._git_value(local_path, "symbolic-ref", "refs/remotes/origin/HEAD")
|
|
387
|
+
if reference:
|
|
388
|
+
return reference.rsplit("/", 1)[-1]
|
|
389
|
+
return cls._git_value(local_path, "branch", "--show-current")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from campfire_cli.app.workspace.schema.restructure_schema import (
|
|
6
|
+
InventoryItem,
|
|
7
|
+
RestructurePlan,
|
|
8
|
+
RestructureResult,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RestructureRepositoryProtocol(Protocol):
|
|
13
|
+
def save_batch(self, batch_uuid: str, batch: str, scope: str, config_hash: str) -> None: ...
|
|
14
|
+
def save_inventory(self, batch: str, items: list[InventoryItem]) -> None: ...
|
|
15
|
+
def load_inventory(self, batch: str) -> tuple[str, str, list[InventoryItem]]: ...
|
|
16
|
+
def save_plan(self, plan: RestructurePlan) -> None: ...
|
|
17
|
+
def load_plan(self, batch: str) -> RestructurePlan: ...
|
|
18
|
+
def save_execution(self, result: RestructureResult) -> None: ...
|
|
19
|
+
def save_verification(self, result: RestructureResult) -> None: ...
|