evo-cli 0.11.3__tar.gz → 0.11.4__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 (55) hide show
  1. {evo_cli-0.11.3 → evo_cli-0.11.4}/PKG-INFO +23 -1
  2. {evo_cli-0.11.3 → evo_cli-0.11.4}/README.md +21 -0
  3. evo_cli-0.11.4/evo_cli/VERSION +1 -0
  4. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/cli.py +2 -0
  5. evo_cli-0.11.4/evo_cli/commands/harness.py +213 -0
  6. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/PKG-INFO +23 -1
  7. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/SOURCES.txt +2 -0
  8. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/requires.txt +1 -0
  9. {evo_cli-0.11.3 → evo_cli-0.11.4}/pyproject.toml +1 -0
  10. evo_cli-0.11.4/tests/test_harness.py +136 -0
  11. evo_cli-0.11.3/evo_cli/VERSION +0 -1
  12. {evo_cli-0.11.3 → evo_cli-0.11.4}/Containerfile +0 -0
  13. {evo_cli-0.11.3 → evo_cli-0.11.4}/HISTORY.md +0 -0
  14. {evo_cli-0.11.3 → evo_cli-0.11.4}/LICENSE +0 -0
  15. {evo_cli-0.11.3 → evo_cli-0.11.4}/MANIFEST.in +0 -0
  16. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/base.py +0 -0
  19. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/claude_code.py +0 -0
  21. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/cloudflare.py +0 -0
  22. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/hwid.py +0 -0
  27. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/hwid_reset.py +0 -0
  28. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/localproxy.py +0 -0
  29. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/mcp.py +0 -0
  30. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/miniconda.py +0 -0
  31. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/netcheck.py +0 -0
  32. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/opencode.py +0 -0
  33. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/plantuml.py +0 -0
  34. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/site2s.py +0 -0
  35. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/ssh.py +0 -0
  36. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/sysmon.py +0 -0
  37. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/commands/wifi.py +0 -0
  38. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/console.py +0 -0
  39. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli/mcp_registry.py +0 -0
  40. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/dependency_links.txt +0 -0
  41. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/entry_points.txt +0 -0
  42. {evo_cli-0.11.3 → evo_cli-0.11.4}/evo_cli.egg-info/top_level.txt +0 -0
  43. {evo_cli-0.11.3 → evo_cli-0.11.4}/setup.cfg +0 -0
  44. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/__init__.py +0 -0
  45. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_claude_code.py +0 -0
  46. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_cli.py +0 -0
  47. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_console.py +0 -0
  48. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_download.py +0 -0
  49. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_fix_claude.py +0 -0
  50. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_gh.py +0 -0
  51. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_mcp.py +0 -0
  52. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_opencode.py +0 -0
  53. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_plantuml.py +0 -0
  54. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_sysmon.py +0 -0
  55. {evo_cli-0.11.3 → evo_cli-0.11.4}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.11.3
3
+ Version: 0.11.4
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -16,6 +16,7 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: cryptography>=41.0
18
18
  Requires-Dist: paramiko>=2.7.0
19
+ Requires-Dist: PyYAML>=6.0
19
20
  Requires-Dist: rich>=13.0
20
21
  Requires-Dist: rich-click>=1.8
21
22
  Provides-Extra: test
@@ -112,3 +113,24 @@ Options:
112
113
  - `-y, --yes` - Skip the confirmation prompt
113
114
  - `-f, --force` - Apply the fix even if the version is not in the affected range
114
115
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
116
+
117
+ #### Harness Repositories
118
+
119
+ Fast-forward every available repository declared in a harness manifest:
120
+
121
+ ```bash
122
+ evo harness pull
123
+ ```
124
+
125
+ Run the command from a harness directory or any registered member repository. Use `--harness PATH`
126
+ when a repository belongs to multiple harnesses or the harness is not registered.
127
+
128
+ ```bash
129
+ evo harness pull --harness ~/github/my-project-harness
130
+ evo harness pull --repo backend --repo frontend
131
+ evo harness pull --dry-run
132
+ ```
133
+
134
+ The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
135
+ `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
136
+ uses `git pull --ff-only` so the command never creates merge commits.
@@ -85,3 +85,24 @@ Options:
85
85
  - `-y, --yes` - Skip the confirmation prompt
86
86
  - `-f, --force` - Apply the fix even if the version is not in the affected range
87
87
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
88
+
89
+ #### Harness Repositories
90
+
91
+ Fast-forward every available repository declared in a harness manifest:
92
+
93
+ ```bash
94
+ evo harness pull
95
+ ```
96
+
97
+ Run the command from a harness directory or any registered member repository. Use `--harness PATH`
98
+ when a repository belongs to multiple harnesses or the harness is not registered.
99
+
100
+ ```bash
101
+ evo harness pull --harness ~/github/my-project-harness
102
+ evo harness pull --repo backend --repo frontend
103
+ evo harness pull --dry-run
104
+ ```
105
+
106
+ The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
107
+ `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
108
+ uses `git pull --ff-only` so the command never creates merge commits.
@@ -0,0 +1 @@
1
+ 0.11.4
@@ -7,6 +7,7 @@ from evo_cli.commands.download import download
7
7
  from evo_cli.commands.fix_claude import f_claude
8
8
  from evo_cli.commands.gdrive import gdrive
9
9
  from evo_cli.commands.gh import setup_gh
10
+ from evo_cli.commands.harness import harness_group
10
11
  from evo_cli.commands.hwid import hwid
11
12
  from evo_cli.commands.hwid_reset import hwid_reset
12
13
  from evo_cli.commands.localproxy import localproxy
@@ -58,6 +59,7 @@ cli.add_command(cfssh)
58
59
  cli.add_command(download)
59
60
  cli.add_command(f_claude)
60
61
  cli.add_command(gdrive)
62
+ cli.add_command(harness_group)
61
63
  cli.add_command(hwid)
62
64
  cli.add_command(hwid_reset)
63
65
  cli.add_command(site2s)
@@ -0,0 +1,213 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ import yaml
9
+
10
+
11
+ def _read_yaml(path, required=True):
12
+ if not path.is_file():
13
+ if required:
14
+ raise click.ClickException(f"Harness manifest not found: {path}")
15
+ return {}
16
+ try:
17
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
18
+ except (OSError, yaml.YAMLError) as exc:
19
+ raise click.ClickException(f"Cannot read {path}: {exc}") from exc
20
+ if not isinstance(data, dict):
21
+ raise click.ClickException(f"Expected a YAML mapping in {path}")
22
+ return data
23
+
24
+
25
+ def _resolve_path(value, base):
26
+ path = Path(os.path.expandvars(str(value))).expanduser()
27
+ return (path if path.is_absolute() else base / path).resolve()
28
+
29
+
30
+ def _load_repos(manifest_path):
31
+ manifest = _read_yaml(manifest_path)
32
+ local = _read_yaml(manifest_path.with_name("harness.local.yaml"), required=False)
33
+ root = manifest_path.parent.resolve()
34
+ workspace_value = (
35
+ os.environ.get("EVO_HARNESS_WORKSPACE") or local.get("workspace") or manifest.get("workspace") or root.parent
36
+ )
37
+ workspace = _resolve_path(workspace_value, root)
38
+ entries = manifest.get("repos", [])
39
+ if not isinstance(entries, list):
40
+ raise click.ClickException(f"Expected 'repos' to be a list in {manifest_path}")
41
+ present_overrides = local.get("present", {}) or {}
42
+ if not isinstance(present_overrides, dict):
43
+ raise click.ClickException(
44
+ f"Expected 'present' to be a mapping in {manifest_path.with_name('harness.local.yaml')}"
45
+ )
46
+
47
+ repos = []
48
+ names = set()
49
+ for index, entry in enumerate(entries):
50
+ if not isinstance(entry, dict):
51
+ raise click.ClickException(f"Expected repos[{index}] to be a mapping in {manifest_path}")
52
+ name = str(entry.get("name", "")).strip()
53
+ if not name:
54
+ raise click.ClickException(f"Expected repos[{index}].name in {manifest_path}")
55
+ if name in names:
56
+ raise click.ClickException(f"Duplicate repo name '{name}' in {manifest_path}")
57
+ names.add(name)
58
+ fallback = workspace / name
59
+ declared = _resolve_path(entry["path"], workspace) if entry.get("path") else None
60
+ path = declared if declared and declared.exists() else fallback.resolve()
61
+ present = present_overrides.get(name, entry.get("present", True))
62
+ repos.append({"name": name, "path": path, "present": bool(present)})
63
+ return manifest, repos
64
+
65
+
66
+ def _contains(parent, child):
67
+ try:
68
+ child.relative_to(parent)
69
+ return True
70
+ except ValueError:
71
+ return False
72
+
73
+
74
+ def _as_manifest(value):
75
+ path = _resolve_path(value, Path.cwd())
76
+ return path / "harness.yaml" if path.is_dir() else path
77
+
78
+
79
+ def _find_manifest(value=None):
80
+ configured = value or os.environ.get("EVO_HARNESS")
81
+ if configured:
82
+ manifest_path = _as_manifest(configured)
83
+ if not manifest_path.is_file():
84
+ raise click.ClickException(f"Harness manifest not found: {manifest_path}")
85
+ return manifest_path
86
+
87
+ cwd = Path.cwd().resolve()
88
+ for base in (cwd, *cwd.parents):
89
+ candidate = base / "harness.yaml"
90
+ if candidate.is_file():
91
+ return candidate
92
+
93
+ registry_path = _resolve_path(
94
+ os.environ.get("EVO_HARNESS_REGISTRY", Path.home() / ".claude" / "harness" / "registry.json"),
95
+ Path.cwd(),
96
+ )
97
+ matches = []
98
+ if registry_path.is_file():
99
+ try:
100
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
101
+ except (OSError, json.JSONDecodeError) as exc:
102
+ raise click.ClickException(f"Cannot read {registry_path}: {exc}") from exc
103
+ for cluster in registry.get("clusters", []):
104
+ if not isinstance(cluster, dict) or not cluster.get("root"):
105
+ continue
106
+ candidate = _as_manifest(cluster["root"])
107
+ if not candidate.is_file():
108
+ continue
109
+ try:
110
+ _, repos = _load_repos(candidate)
111
+ except click.ClickException:
112
+ continue
113
+ root = candidate.parent.resolve()
114
+ if _contains(root, cwd) or any(_contains(repo["path"], cwd) for repo in repos):
115
+ matches.append(candidate)
116
+
117
+ matches = list(dict.fromkeys(matches))
118
+ if len(matches) == 1:
119
+ return matches[0]
120
+ if len(matches) > 1:
121
+ choices = ", ".join(str(path.parent) for path in matches)
122
+ raise click.ClickException(f"Current directory belongs to multiple harnesses: {choices}. Use --harness PATH.")
123
+ raise click.ClickException("Cannot find harness.yaml. Run inside a harness repo or use --harness PATH.")
124
+
125
+
126
+ def _git(path, *args):
127
+ return subprocess.run(
128
+ ["git", "-C", str(path), *args],
129
+ capture_output=True,
130
+ text=True,
131
+ encoding="utf-8",
132
+ errors="replace",
133
+ check=False,
134
+ )
135
+
136
+
137
+ @click.group("harness", help="Manage repositories declared by a harness manifest.")
138
+ def harness_group():
139
+ pass
140
+
141
+
142
+ @harness_group.command("pull", help="Fast-forward all available repositories in a harness.")
143
+ @click.option(
144
+ "--harness",
145
+ "harness_path",
146
+ type=click.Path(path_type=Path),
147
+ help="Harness directory or harness.yaml path. Defaults to auto-discovery.",
148
+ )
149
+ @click.option("--repo", "repos", multiple=True, help="Pull only this repo name. Repeat for multiple repos.")
150
+ @click.option("--prune/--no-prune", default=True, show_default=True, help="Prune deleted remote refs while pulling.")
151
+ @click.option("--dry-run", is_flag=True, help="Show repositories that would be pulled.")
152
+ def pull(harness_path, repos, prune, dry_run):
153
+ if shutil.which("git") is None:
154
+ raise click.ClickException("git is not installed or is not on PATH")
155
+
156
+ manifest_path = _find_manifest(harness_path)
157
+ manifest, entries = _load_repos(manifest_path)
158
+ available = {entry["name"] for entry in entries}
159
+ unknown = sorted(set(repos) - available)
160
+ if unknown:
161
+ raise click.ClickException(f"Unknown repo name(s): {', '.join(unknown)}")
162
+ selected = [entry for entry in entries if not repos or entry["name"] in repos]
163
+
164
+ click.echo(f"Harness: {manifest.get('name') or manifest_path.parent.name}")
165
+ click.echo(f"Manifest: {manifest_path}")
166
+ results = []
167
+ incomplete = 0
168
+
169
+ for entry in selected:
170
+ name = entry["name"]
171
+ path = entry["path"]
172
+ if not entry["present"]:
173
+ results.append((name, "skipped (present: false)"))
174
+ continue
175
+ if not path.is_dir():
176
+ results.append((name, f"failed (missing: {path})"))
177
+ incomplete += 1
178
+ continue
179
+
180
+ status = _git(path, "status", "--porcelain")
181
+ if status.returncode != 0:
182
+ detail = (status.stderr or status.stdout).strip().splitlines()
183
+ results.append((name, f"failed ({detail[-1] if detail else 'not a git repository'})"))
184
+ incomplete += 1
185
+ continue
186
+ if status.stdout.strip():
187
+ results.append((name, "skipped (uncommitted changes)"))
188
+ incomplete += 1
189
+ continue
190
+ if dry_run:
191
+ results.append((name, "would pull"))
192
+ continue
193
+
194
+ args = ["pull", "--ff-only"]
195
+ if prune:
196
+ args.append("--prune")
197
+ update = _git(path, *args)
198
+ if update.returncode != 0:
199
+ detail = (update.stderr or update.stdout).strip().splitlines()
200
+ results.append((name, f"failed ({detail[-1] if detail else 'git pull failed'})"))
201
+ incomplete += 1
202
+ continue
203
+ head = _git(path, "log", "-1", "--oneline")
204
+ results.append((name, head.stdout.strip() or "updated"))
205
+
206
+ click.echo()
207
+ click.secho("Summary", bold=True)
208
+ width = max((len(name) for name, _ in results), default=0)
209
+ for name, status in results:
210
+ color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
211
+ click.secho(f" {name:<{width}} {status}", fg=color)
212
+ if incomplete:
213
+ raise click.exceptions.Exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.11.3
3
+ Version: 0.11.4
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -16,6 +16,7 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: cryptography>=41.0
18
18
  Requires-Dist: paramiko>=2.7.0
19
+ Requires-Dist: PyYAML>=6.0
19
20
  Requires-Dist: rich>=13.0
20
21
  Requires-Dist: rich-click>=1.8
21
22
  Provides-Extra: test
@@ -112,3 +113,24 @@ Options:
112
113
  - `-y, --yes` - Skip the confirmation prompt
113
114
  - `-f, --force` - Apply the fix even if the version is not in the affected range
114
115
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
116
+
117
+ #### Harness Repositories
118
+
119
+ Fast-forward every available repository declared in a harness manifest:
120
+
121
+ ```bash
122
+ evo harness pull
123
+ ```
124
+
125
+ Run the command from a harness directory or any registered member repository. Use `--harness PATH`
126
+ when a repository belongs to multiple harnesses or the harness is not registered.
127
+
128
+ ```bash
129
+ evo harness pull --harness ~/github/my-project-harness
130
+ evo harness pull --repo backend --repo frontend
131
+ evo harness pull --dry-run
132
+ ```
133
+
134
+ The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
135
+ `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
136
+ uses `git pull --ff-only` so the command never creates merge commits.
@@ -24,6 +24,7 @@ evo_cli/commands/download.py
24
24
  evo_cli/commands/fix_claude.py
25
25
  evo_cli/commands/gdrive.py
26
26
  evo_cli/commands/gh.py
27
+ evo_cli/commands/harness.py
27
28
  evo_cli/commands/hwid.py
28
29
  evo_cli/commands/hwid_reset.py
29
30
  evo_cli/commands/localproxy.py
@@ -43,6 +44,7 @@ tests/test_console.py
43
44
  tests/test_download.py
44
45
  tests/test_fix_claude.py
45
46
  tests/test_gh.py
47
+ tests/test_harness.py
46
48
  tests/test_mcp.py
47
49
  tests/test_opencode.py
48
50
  tests/test_plantuml.py
@@ -1,5 +1,6 @@
1
1
  cryptography>=41.0
2
2
  paramiko>=2.7.0
3
+ PyYAML>=6.0
3
4
  rich>=13.0
4
5
  rich-click>=1.8
5
6
 
@@ -20,6 +20,7 @@ classifiers = [
20
20
  dependencies = [
21
21
  "cryptography>=41.0",
22
22
  "paramiko>=2.7.0",
23
+ "PyYAML>=6.0",
23
24
  "rich>=13.0",
24
25
  "rich-click>=1.8",
25
26
  ]
@@ -0,0 +1,136 @@
1
+ import json
2
+ import subprocess
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from evo_cli.cli import cli
7
+ from evo_cli.commands import harness as harness_command
8
+
9
+
10
+ def _result(returncode=0, stdout="", stderr=""):
11
+ return subprocess.CompletedProcess([], returncode, stdout, stderr)
12
+
13
+
14
+ def _write_harness(root, workspace):
15
+ root.mkdir()
16
+ (root / "harness.yaml").write_text(
17
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
18
+ "- name: alpha\n present: true\n"
19
+ "- name: beta\n present: true\n",
20
+ encoding="utf-8",
21
+ )
22
+
23
+
24
+ def test_harness_command_is_registered():
25
+ assert "harness" in cli.commands
26
+ assert "pull" in cli.commands["harness"].commands
27
+
28
+
29
+ def test_pull_dry_run_honors_local_present_override(tmp_path, monkeypatch):
30
+ workspace = tmp_path / "repos"
31
+ workspace.mkdir()
32
+ (workspace / "alpha").mkdir()
33
+ (workspace / "beta").mkdir()
34
+ root = tmp_path / "cluster"
35
+ _write_harness(root, workspace)
36
+ (root / "harness.local.yaml").write_text("present:\n beta: false\n", encoding="utf-8")
37
+ calls = []
38
+
39
+ def fake_git(path, *args):
40
+ calls.append((path, args))
41
+ return _result()
42
+
43
+ monkeypatch.setattr(harness_command, "_git", fake_git)
44
+ result = CliRunner().invoke(cli, ["harness", "pull", "--harness", str(root), "--dry-run"])
45
+
46
+ assert result.exit_code == 0
47
+ assert "alpha would pull" in result.output
48
+ assert "beta skipped (present: false)" in result.output
49
+ assert calls == [(workspace / "alpha", ("status", "--porcelain"))]
50
+
51
+
52
+ def test_pull_refuses_dirty_repo(tmp_path, monkeypatch):
53
+ workspace = tmp_path / "repos"
54
+ workspace.mkdir()
55
+ (workspace / "alpha").mkdir()
56
+ root = tmp_path / "cluster"
57
+ _write_harness(root, workspace)
58
+ (root / "harness.local.yaml").write_text("present:\n beta: false\n", encoding="utf-8")
59
+ monkeypatch.setattr(harness_command, "_git", lambda path, *args: _result(stdout=" M local.txt\n"))
60
+
61
+ result = CliRunner().invoke(cli, ["harness", "pull", "--harness", str(root)])
62
+
63
+ assert result.exit_code == 1
64
+ assert "alpha skipped (uncommitted changes)" in result.output
65
+
66
+
67
+ def test_pull_uses_fast_forward_and_prune(tmp_path, monkeypatch):
68
+ workspace = tmp_path / "repos"
69
+ workspace.mkdir()
70
+ (workspace / "alpha").mkdir()
71
+ root = tmp_path / "cluster"
72
+ _write_harness(root, workspace)
73
+ calls = []
74
+
75
+ def fake_git(path, *args):
76
+ calls.append((path, args))
77
+ if args[0] == "log":
78
+ return _result(stdout="abc123 Latest change\n")
79
+ return _result()
80
+
81
+ monkeypatch.setattr(harness_command, "_git", fake_git)
82
+ result = CliRunner().invoke(cli, ["harness", "pull", "--harness", str(root), "--repo", "alpha"])
83
+
84
+ assert result.exit_code == 0
85
+ assert "abc123 Latest change" in result.output
86
+ assert calls == [
87
+ (workspace / "alpha", ("status", "--porcelain")),
88
+ (workspace / "alpha", ("pull", "--ff-only", "--prune")),
89
+ (workspace / "alpha", ("log", "-1", "--oneline")),
90
+ ]
91
+
92
+
93
+ def test_pull_discovers_manifest_from_environment(tmp_path, monkeypatch):
94
+ workspace = tmp_path / "repos"
95
+ workspace.mkdir()
96
+ (workspace / "alpha").mkdir()
97
+ root = tmp_path / "cluster"
98
+ _write_harness(root, workspace)
99
+ monkeypatch.setenv("EVO_HARNESS", str(root))
100
+ monkeypatch.setattr(harness_command, "_git", lambda path, *args: _result())
101
+
102
+ result = CliRunner().invoke(cli, ["harness", "pull", "--repo", "alpha", "--dry-run"])
103
+
104
+ assert result.exit_code == 0
105
+ assert "Harness: test-cluster" in result.output
106
+
107
+
108
+ def test_pull_discovers_registered_member_repo(tmp_path, monkeypatch):
109
+ workspace = tmp_path / "repos"
110
+ workspace.mkdir()
111
+ member = workspace / "alpha"
112
+ member.mkdir()
113
+ root = tmp_path / "cluster"
114
+ _write_harness(root, workspace)
115
+ registry = tmp_path / "registry.json"
116
+ registry.write_text(json.dumps({"clusters": [{"root": str(root)}]}), encoding="utf-8")
117
+ monkeypatch.chdir(member)
118
+ monkeypatch.setenv("EVO_HARNESS_REGISTRY", str(registry))
119
+ monkeypatch.setattr(harness_command, "_git", lambda path, *args: _result())
120
+
121
+ result = CliRunner().invoke(cli, ["harness", "pull", "--repo", "alpha", "--dry-run"])
122
+
123
+ assert result.exit_code == 0
124
+ assert f"Manifest: {root / 'harness.yaml'}" in result.output
125
+
126
+
127
+ def test_pull_rejects_unknown_repo(tmp_path):
128
+ workspace = tmp_path / "repos"
129
+ workspace.mkdir()
130
+ root = tmp_path / "cluster"
131
+ _write_harness(root, workspace)
132
+
133
+ result = CliRunner().invoke(cli, ["harness", "pull", "--harness", str(root), "--repo", "missing"])
134
+
135
+ assert result.exit_code == 1
136
+ assert "Unknown repo name(s): missing" in result.output
@@ -1 +0,0 @@
1
- 0.11.3
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes