canopy-cli 3.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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/git/__init__.py
ADDED
|
File without changes
|
canopy/git/hooks.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Install, uninstall, and read canopy git hooks.
|
|
2
|
+
|
|
3
|
+
Canopy installs a post-checkout hook in every managed repo and worktree so
|
|
4
|
+
it has real-time ground truth of HEAD per repo without polling. The hook
|
|
5
|
+
writes to .canopy/state/heads.json under the workspace root.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_HOOK_NAME = "post-checkout"
|
|
17
|
+
_CHAINED_NAME = "post-checkout.canopy-chained"
|
|
18
|
+
_MARKER = "__CANOPY_HOOK_MARKER__"
|
|
19
|
+
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "post-checkout.py"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InstallResult:
|
|
24
|
+
repo: str
|
|
25
|
+
path: str
|
|
26
|
+
action: str # "installed", "reinstalled", "chained_existing"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_hook(repo_path: Path, repo_name: str, workspace_root: Path) -> InstallResult:
|
|
30
|
+
"""Install the canopy post-checkout hook in a repo or linked worktree.
|
|
31
|
+
|
|
32
|
+
If a user hook already exists, it's moved to ``post-checkout.canopy-chained``
|
|
33
|
+
and invoked after the canopy hook runs. If a previous canopy hook is
|
|
34
|
+
present, it's replaced.
|
|
35
|
+
"""
|
|
36
|
+
hooks_dir = resolve_hooks_dir(repo_path)
|
|
37
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
39
|
+
|
|
40
|
+
template = _TEMPLATE_PATH.read_text()
|
|
41
|
+
rendered = template.replace(
|
|
42
|
+
'"__CANOPY_REPO__"', json.dumps(repo_name),
|
|
43
|
+
).replace(
|
|
44
|
+
'"__CANOPY_WORKSPACE_ROOT__"', json.dumps(str(workspace_root.resolve())),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
action = "installed"
|
|
48
|
+
if hook_path.exists():
|
|
49
|
+
existing = hook_path.read_text()
|
|
50
|
+
if _MARKER in existing:
|
|
51
|
+
action = "reinstalled"
|
|
52
|
+
else:
|
|
53
|
+
chained = hooks_dir / _CHAINED_NAME
|
|
54
|
+
if chained.exists():
|
|
55
|
+
chained.unlink()
|
|
56
|
+
shutil.move(str(hook_path), str(chained))
|
|
57
|
+
_make_executable(chained)
|
|
58
|
+
action = "chained_existing"
|
|
59
|
+
|
|
60
|
+
hook_path.write_text(rendered)
|
|
61
|
+
_make_executable(hook_path)
|
|
62
|
+
|
|
63
|
+
return InstallResult(repo=repo_name, path=str(hook_path), action=action)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class UninstallResult:
|
|
68
|
+
repo: str
|
|
69
|
+
action: str # "uninstalled", "uninstalled_and_restored", "skipped", "not_installed"
|
|
70
|
+
reason: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def uninstall_hook(repo_path: Path, repo_name: str) -> UninstallResult:
|
|
74
|
+
"""Remove the canopy hook; restore any chained user hook."""
|
|
75
|
+
hooks_dir = resolve_hooks_dir(repo_path)
|
|
76
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
77
|
+
chained = hooks_dir / _CHAINED_NAME
|
|
78
|
+
|
|
79
|
+
if not hook_path.exists():
|
|
80
|
+
return UninstallResult(repo=repo_name, action="not_installed")
|
|
81
|
+
|
|
82
|
+
if _MARKER not in hook_path.read_text():
|
|
83
|
+
return UninstallResult(
|
|
84
|
+
repo=repo_name, action="skipped",
|
|
85
|
+
reason="hook exists but is not a canopy hook",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
hook_path.unlink()
|
|
89
|
+
if chained.exists():
|
|
90
|
+
shutil.move(str(chained), str(hook_path))
|
|
91
|
+
_make_executable(hook_path)
|
|
92
|
+
return UninstallResult(repo=repo_name, action="uninstalled_and_restored")
|
|
93
|
+
return UninstallResult(repo=repo_name, action="uninstalled")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def hook_status(repo_path: Path) -> dict:
|
|
97
|
+
"""Inspect current hook state for a repo."""
|
|
98
|
+
hooks_dir = resolve_hooks_dir(repo_path)
|
|
99
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
100
|
+
chained = hooks_dir / _CHAINED_NAME
|
|
101
|
+
|
|
102
|
+
if not hook_path.exists():
|
|
103
|
+
return {"installed": False, "hook_path": str(hook_path)}
|
|
104
|
+
|
|
105
|
+
content = hook_path.read_text()
|
|
106
|
+
return {
|
|
107
|
+
"installed": _MARKER in content,
|
|
108
|
+
"foreign_hook": _MARKER not in content,
|
|
109
|
+
"chained_present": chained.exists(),
|
|
110
|
+
"hook_path": str(hook_path),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_heads_state(workspace_root: Path) -> dict:
|
|
115
|
+
"""Return ``{repo_name: {branch, sha, prev_sha, ts}}`` from the state file."""
|
|
116
|
+
path = workspace_root / ".canopy" / "state" / "heads.json"
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(path.read_text())
|
|
119
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_hooks_dir(repo_path: Path) -> Path:
|
|
124
|
+
"""Resolve the ``hooks`` dir git actually uses for this repo / worktree.
|
|
125
|
+
|
|
126
|
+
Resolution order:
|
|
127
|
+
1. ``core.hooksPath`` if set in the repo's config (e.g., Husky uses
|
|
128
|
+
``.husky/_``). Relative paths resolve against the repo root.
|
|
129
|
+
Without this we'd install a hook git would never run.
|
|
130
|
+
2. For a linked worktree, follow ``.git`` (file) → worktree gitdir →
|
|
131
|
+
``commondir`` → main ``.git/hooks``. Hooks are shared across all
|
|
132
|
+
worktrees of a repo by default.
|
|
133
|
+
3. Otherwise ``<repo>/.git/hooks``.
|
|
134
|
+
"""
|
|
135
|
+
custom = _get_core_hooks_path(repo_path)
|
|
136
|
+
if custom is not None:
|
|
137
|
+
return custom
|
|
138
|
+
|
|
139
|
+
git_path = repo_path / ".git"
|
|
140
|
+
if git_path.is_file():
|
|
141
|
+
contents = git_path.read_text().strip()
|
|
142
|
+
if contents.startswith("gitdir:"):
|
|
143
|
+
worktree_gitdir = Path(contents.split(":", 1)[1].strip())
|
|
144
|
+
if not worktree_gitdir.is_absolute():
|
|
145
|
+
worktree_gitdir = (repo_path / worktree_gitdir).resolve()
|
|
146
|
+
commondir_file = worktree_gitdir / "commondir"
|
|
147
|
+
if commondir_file.is_file():
|
|
148
|
+
common = Path(commondir_file.read_text().strip())
|
|
149
|
+
if not common.is_absolute():
|
|
150
|
+
common = (worktree_gitdir / common).resolve()
|
|
151
|
+
return common / "hooks"
|
|
152
|
+
return worktree_gitdir / "hooks"
|
|
153
|
+
return git_path / "hooks"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_core_hooks_path(repo_path: Path) -> Path | None:
|
|
157
|
+
"""Read ``core.hooksPath`` from the repo's config. Returns None if unset."""
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
["git", "config", "--get", "core.hooksPath"],
|
|
160
|
+
cwd=repo_path, capture_output=True, text=True, check=False,
|
|
161
|
+
)
|
|
162
|
+
value = result.stdout.strip()
|
|
163
|
+
if result.returncode != 0 or not value:
|
|
164
|
+
return None
|
|
165
|
+
p = Path(value)
|
|
166
|
+
if not p.is_absolute():
|
|
167
|
+
p = (repo_path / p).resolve()
|
|
168
|
+
return p
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _make_executable(path: Path) -> None:
|
|
172
|
+
mode = path.stat().st_mode
|
|
173
|
+
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
canopy/git/multi.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-repo Git operations.
|
|
3
|
+
|
|
4
|
+
Calls git.repo functions across multiple repos in a workspace.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..workspace.workspace import Workspace, RepoState
|
|
12
|
+
from . import repo as git
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def workspace_status(workspace: Workspace) -> list[RepoState]:
|
|
16
|
+
"""Refresh and return enriched state for all repos."""
|
|
17
|
+
workspace.refresh()
|
|
18
|
+
return workspace.repos
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_branch_all(
|
|
22
|
+
workspace: Workspace,
|
|
23
|
+
branch: str,
|
|
24
|
+
repos: list[str] | None = None,
|
|
25
|
+
) -> dict[str, bool | str]:
|
|
26
|
+
"""Create a branch in all (or specified) repos.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
{repo_name: True} on success, {repo_name: error_message} on failure.
|
|
30
|
+
"""
|
|
31
|
+
results: dict[str, bool | str] = {}
|
|
32
|
+
targets = _filter_repos(workspace, repos)
|
|
33
|
+
|
|
34
|
+
for state in targets:
|
|
35
|
+
try:
|
|
36
|
+
if git.branch_exists(state.abs_path, branch):
|
|
37
|
+
results[state.config.name] = True # already exists
|
|
38
|
+
else:
|
|
39
|
+
git.create_branch(state.abs_path, branch)
|
|
40
|
+
results[state.config.name] = True
|
|
41
|
+
except git.GitError as e:
|
|
42
|
+
results[state.config.name] = str(e)
|
|
43
|
+
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def checkout_all(
|
|
48
|
+
workspace: Workspace,
|
|
49
|
+
branch: str,
|
|
50
|
+
repos: list[str] | None = None,
|
|
51
|
+
) -> dict[str, bool | str]:
|
|
52
|
+
"""Checkout a branch in all (or specified) repos.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
{repo_name: True} on success, {repo_name: error_message} on failure.
|
|
56
|
+
"""
|
|
57
|
+
results: dict[str, bool | str] = {}
|
|
58
|
+
targets = _filter_repos(workspace, repos)
|
|
59
|
+
|
|
60
|
+
for state in targets:
|
|
61
|
+
try:
|
|
62
|
+
if not git.branch_exists(state.abs_path, branch):
|
|
63
|
+
results[state.config.name] = f"branch '{branch}' does not exist"
|
|
64
|
+
continue
|
|
65
|
+
git.checkout(state.abs_path, branch)
|
|
66
|
+
results[state.config.name] = True
|
|
67
|
+
except git.GitError as e:
|
|
68
|
+
results[state.config.name] = str(e)
|
|
69
|
+
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cross_repo_diff(workspace: Workspace, feature: str) -> dict:
|
|
74
|
+
"""Aggregate diff across all repos for a feature branch vs default.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
{
|
|
78
|
+
repo_name: {
|
|
79
|
+
files_changed, insertions, deletions,
|
|
80
|
+
changed_files, has_branch
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
result = {}
|
|
85
|
+
|
|
86
|
+
for state in workspace.repos:
|
|
87
|
+
base = state.config.default_branch
|
|
88
|
+
repo_name = state.config.name
|
|
89
|
+
|
|
90
|
+
if not git.branch_exists(state.abs_path, feature):
|
|
91
|
+
result[repo_name] = {
|
|
92
|
+
"has_branch": False,
|
|
93
|
+
"files_changed": 0,
|
|
94
|
+
"insertions": 0,
|
|
95
|
+
"deletions": 0,
|
|
96
|
+
"changed_files": [],
|
|
97
|
+
}
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
stat = git.diff_stat(state.abs_path, base, feature)
|
|
102
|
+
files = git.changed_files(state.abs_path, feature, base)
|
|
103
|
+
result[repo_name] = {
|
|
104
|
+
"has_branch": True,
|
|
105
|
+
"files_changed": stat["files_changed"],
|
|
106
|
+
"insertions": stat["insertions"],
|
|
107
|
+
"deletions": stat["deletions"],
|
|
108
|
+
"changed_files": files,
|
|
109
|
+
}
|
|
110
|
+
except git.GitError as e:
|
|
111
|
+
result[repo_name] = {
|
|
112
|
+
"has_branch": True,
|
|
113
|
+
"error": str(e),
|
|
114
|
+
"files_changed": 0,
|
|
115
|
+
"insertions": 0,
|
|
116
|
+
"deletions": 0,
|
|
117
|
+
"changed_files": [],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_type_overlaps(workspace: Workspace, feature: str) -> list[dict]:
|
|
124
|
+
"""Find files with similar names changed across multiple repos.
|
|
125
|
+
|
|
126
|
+
Looks for potential shared type/interface conflicts where, e.g.,
|
|
127
|
+
api/src/models.py and ui/src/types.ts both change user-related types.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
[{file_pattern, repos: [repo_names], files: [{repo, path}]}]
|
|
131
|
+
"""
|
|
132
|
+
# Collect changed file basenames per repo
|
|
133
|
+
file_map: dict[str, list[dict]] = {}
|
|
134
|
+
|
|
135
|
+
for state in workspace.repos:
|
|
136
|
+
base = state.config.default_branch
|
|
137
|
+
if not git.branch_exists(state.abs_path, feature):
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
files = git.changed_files(state.abs_path, feature, base)
|
|
141
|
+
except git.GitError:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
for f in files:
|
|
145
|
+
basename = os.path.splitext(os.path.basename(f))[0].lower()
|
|
146
|
+
# Normalize common type-related names
|
|
147
|
+
file_map.setdefault(basename, []).append({
|
|
148
|
+
"repo": state.config.name,
|
|
149
|
+
"path": f,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
# Filter to basenames that appear in 2+ repos
|
|
153
|
+
overlaps = []
|
|
154
|
+
for basename, entries in file_map.items():
|
|
155
|
+
repos = {e["repo"] for e in entries}
|
|
156
|
+
if len(repos) >= 2:
|
|
157
|
+
overlaps.append({
|
|
158
|
+
"file_pattern": basename,
|
|
159
|
+
"repos": sorted(repos),
|
|
160
|
+
"files": entries,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return overlaps
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sync_all(
|
|
167
|
+
workspace: Workspace,
|
|
168
|
+
strategy: str = "rebase",
|
|
169
|
+
) -> dict[str, str]:
|
|
170
|
+
"""Pull default branch and rebase/merge feature branches.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
{repo_name: "ok" | error_message}
|
|
174
|
+
"""
|
|
175
|
+
results: dict[str, str] = {}
|
|
176
|
+
|
|
177
|
+
for state in workspace.repos:
|
|
178
|
+
try:
|
|
179
|
+
base = state.config.default_branch
|
|
180
|
+
current = git.current_branch(state.abs_path)
|
|
181
|
+
|
|
182
|
+
# First, update the default branch
|
|
183
|
+
git.checkout(state.abs_path, base)
|
|
184
|
+
git.pull_rebase(state.abs_path)
|
|
185
|
+
|
|
186
|
+
# If we were on a feature branch, rebase it
|
|
187
|
+
if current != base and current != "(detached)":
|
|
188
|
+
git.checkout(state.abs_path, current)
|
|
189
|
+
if strategy == "rebase":
|
|
190
|
+
git._run(["rebase", base], cwd=state.abs_path)
|
|
191
|
+
else:
|
|
192
|
+
git._run(["merge", base], cwd=state.abs_path)
|
|
193
|
+
|
|
194
|
+
results[state.config.name] = "ok"
|
|
195
|
+
except git.GitError as e:
|
|
196
|
+
results[state.config.name] = str(e)
|
|
197
|
+
# Try to recover: abort rebase if in progress
|
|
198
|
+
try:
|
|
199
|
+
git._run_ok(["rebase", "--abort"], cwd=state.abs_path)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
return results
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def stash_save_all(
|
|
207
|
+
workspace: Workspace,
|
|
208
|
+
message: str = "",
|
|
209
|
+
repos: list[str] | None = None,
|
|
210
|
+
include_untracked: bool = False,
|
|
211
|
+
) -> dict[str, str]:
|
|
212
|
+
"""Stash uncommitted changes across repos.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
{repo_name: "stashed" | "clean" | error_message}
|
|
216
|
+
"""
|
|
217
|
+
results: dict[str, str] = {}
|
|
218
|
+
targets = _filter_repos(workspace, repos)
|
|
219
|
+
|
|
220
|
+
for state in targets:
|
|
221
|
+
try:
|
|
222
|
+
stashed = git.stash_save(
|
|
223
|
+
state.abs_path, message, include_untracked=include_untracked,
|
|
224
|
+
)
|
|
225
|
+
results[state.config.name] = "stashed" if stashed else "clean"
|
|
226
|
+
except git.GitError as e:
|
|
227
|
+
results[state.config.name] = str(e)
|
|
228
|
+
|
|
229
|
+
return results
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def stash_pop_all(
|
|
233
|
+
workspace: Workspace,
|
|
234
|
+
index: int = 0,
|
|
235
|
+
repos: list[str] | None = None,
|
|
236
|
+
) -> dict[str, str]:
|
|
237
|
+
"""Pop stash across repos.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
{repo_name: "ok" | "no stash" | error_message}
|
|
241
|
+
"""
|
|
242
|
+
results: dict[str, str] = {}
|
|
243
|
+
targets = _filter_repos(workspace, repos)
|
|
244
|
+
|
|
245
|
+
for state in targets:
|
|
246
|
+
stashes = git.stash_list(state.abs_path)
|
|
247
|
+
if not stashes:
|
|
248
|
+
results[state.config.name] = "no stash"
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
git.stash_pop(state.abs_path, index)
|
|
252
|
+
results[state.config.name] = "ok"
|
|
253
|
+
except git.GitError as e:
|
|
254
|
+
results[state.config.name] = str(e)
|
|
255
|
+
|
|
256
|
+
return results
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def stash_list_all(workspace: Workspace) -> dict[str, list[dict]]:
|
|
260
|
+
"""List stashes across all repos.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
{repo_name: [{index, ref, message}, ...]}
|
|
264
|
+
"""
|
|
265
|
+
results: dict[str, list[dict]] = {}
|
|
266
|
+
|
|
267
|
+
for state in workspace.repos:
|
|
268
|
+
stashes = git.stash_list(state.abs_path)
|
|
269
|
+
if stashes:
|
|
270
|
+
results[state.config.name] = stashes
|
|
271
|
+
|
|
272
|
+
return results
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def stash_drop_all(
|
|
276
|
+
workspace: Workspace,
|
|
277
|
+
index: int = 0,
|
|
278
|
+
repos: list[str] | None = None,
|
|
279
|
+
) -> dict[str, str]:
|
|
280
|
+
"""Drop stash entry across repos.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
{repo_name: "ok" | "no stash" | error_message}
|
|
284
|
+
"""
|
|
285
|
+
results: dict[str, str] = {}
|
|
286
|
+
targets = _filter_repos(workspace, repos)
|
|
287
|
+
|
|
288
|
+
for state in targets:
|
|
289
|
+
stashes = git.stash_list(state.abs_path)
|
|
290
|
+
if not stashes:
|
|
291
|
+
results[state.config.name] = "no stash"
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
git.stash_drop(state.abs_path, index)
|
|
295
|
+
results[state.config.name] = "ok"
|
|
296
|
+
except git.GitError as e:
|
|
297
|
+
results[state.config.name] = str(e)
|
|
298
|
+
|
|
299
|
+
return results
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def commit_all(
|
|
303
|
+
workspace: Workspace,
|
|
304
|
+
message: str,
|
|
305
|
+
repos: list[str] | None = None,
|
|
306
|
+
) -> dict[str, str]:
|
|
307
|
+
"""Commit staged changes across repos with the same message.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
{repo_name: new_sha | "nothing to commit" | error_message}
|
|
311
|
+
"""
|
|
312
|
+
results: dict[str, str] = {}
|
|
313
|
+
targets = _filter_repos(workspace, repos)
|
|
314
|
+
|
|
315
|
+
for state in targets:
|
|
316
|
+
status = git.status_porcelain(state.abs_path)
|
|
317
|
+
staged = [e for e in status if e["index_status"]]
|
|
318
|
+
if not staged:
|
|
319
|
+
results[state.config.name] = "nothing to commit"
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
result = git.commit(state.abs_path, message)
|
|
323
|
+
results[state.config.name] = result["sha"][:12]
|
|
324
|
+
except git.GitError as e:
|
|
325
|
+
results[state.config.name] = str(e)
|
|
326
|
+
|
|
327
|
+
return results
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def log_all(
|
|
331
|
+
workspace: Workspace,
|
|
332
|
+
max_count: int = 20,
|
|
333
|
+
feature: str | None = None,
|
|
334
|
+
) -> list[dict]:
|
|
335
|
+
"""Interleaved log across repos, sorted by date.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
List of {repo, sha, short_sha, author, date, subject}
|
|
339
|
+
"""
|
|
340
|
+
all_entries = []
|
|
341
|
+
|
|
342
|
+
for state in workspace.repos:
|
|
343
|
+
ref = feature if feature and git.branch_exists(state.abs_path, feature) else "HEAD"
|
|
344
|
+
entries = git.log_structured(state.abs_path, ref=ref, max_count=max_count)
|
|
345
|
+
for entry in entries:
|
|
346
|
+
entry["repo"] = state.config.name
|
|
347
|
+
all_entries.append(entry)
|
|
348
|
+
|
|
349
|
+
# Sort by date descending
|
|
350
|
+
all_entries.sort(key=lambda e: e["date"], reverse=True)
|
|
351
|
+
return all_entries[:max_count]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def delete_branch_all(
|
|
355
|
+
workspace: Workspace,
|
|
356
|
+
branch: str,
|
|
357
|
+
force: bool = False,
|
|
358
|
+
repos: list[str] | None = None,
|
|
359
|
+
) -> dict[str, str]:
|
|
360
|
+
"""Delete a branch across repos.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
{repo_name: "ok" | "not found" | error_message}
|
|
364
|
+
"""
|
|
365
|
+
results: dict[str, str] = {}
|
|
366
|
+
targets = _filter_repos(workspace, repos)
|
|
367
|
+
|
|
368
|
+
for state in targets:
|
|
369
|
+
if not git.branch_exists(state.abs_path, branch):
|
|
370
|
+
results[state.config.name] = "not found"
|
|
371
|
+
continue
|
|
372
|
+
# Don't delete the branch we're currently on
|
|
373
|
+
if git.current_branch(state.abs_path) == branch:
|
|
374
|
+
results[state.config.name] = "currently checked out"
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
git.delete_branch(state.abs_path, branch, force=force)
|
|
378
|
+
results[state.config.name] = "ok"
|
|
379
|
+
except git.GitError as e:
|
|
380
|
+
results[state.config.name] = str(e)
|
|
381
|
+
|
|
382
|
+
return results
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def rename_branch_all(
|
|
386
|
+
workspace: Workspace,
|
|
387
|
+
old_name: str,
|
|
388
|
+
new_name: str,
|
|
389
|
+
repos: list[str] | None = None,
|
|
390
|
+
) -> dict[str, str]:
|
|
391
|
+
"""Rename a branch across repos.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
{repo_name: "ok" | "not found" | error_message}
|
|
395
|
+
"""
|
|
396
|
+
results: dict[str, str] = {}
|
|
397
|
+
targets = _filter_repos(workspace, repos)
|
|
398
|
+
|
|
399
|
+
for state in targets:
|
|
400
|
+
if not git.branch_exists(state.abs_path, old_name):
|
|
401
|
+
results[state.config.name] = "not found"
|
|
402
|
+
continue
|
|
403
|
+
try:
|
|
404
|
+
git.rename_branch(state.abs_path, old_name, new_name)
|
|
405
|
+
results[state.config.name] = "ok"
|
|
406
|
+
except git.GitError as e:
|
|
407
|
+
results[state.config.name] = str(e)
|
|
408
|
+
|
|
409
|
+
return results
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def branches_all(workspace: Workspace) -> dict[str, list[dict]]:
|
|
413
|
+
"""List all branches across repos.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
{repo_name: [{name, is_current, sha, subject}, ...]}
|
|
417
|
+
"""
|
|
418
|
+
results: dict[str, list[dict]] = {}
|
|
419
|
+
|
|
420
|
+
for state in workspace.repos:
|
|
421
|
+
entries = git.all_branches(state.abs_path)
|
|
422
|
+
results[state.config.name] = entries
|
|
423
|
+
|
|
424
|
+
return results
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _filter_repos(
|
|
428
|
+
workspace: Workspace,
|
|
429
|
+
repo_names: list[str] | None,
|
|
430
|
+
) -> list[RepoState]:
|
|
431
|
+
"""Filter repos by name, or return all if names is None."""
|
|
432
|
+
if repo_names is None:
|
|
433
|
+
return workspace.repos
|
|
434
|
+
name_set = set(repo_names)
|
|
435
|
+
return [s for s in workspace.repos if s.config.name in name_set]
|