diff --git a/README.md b/README.md index 878ea96..60aab1e 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ turn 可以真正并发(不是轮流排队)。 | 多机器 state 冲突 | 锁只防同一台机器,多机器共享 state 文件会静默覆盖 | 部分修:state 加 version/last_writer/updated_at,保存时磁盘版本比加载时新就记 STATE-CONFLICT 日志并合并保留双方任务,不静默丢;真多机同步仍需中心服务(见下“没对齐”) | | 合并时提交缓存文件 | merge 的 git add -A 会把 __pycache__/*.pyc 也提交进主仓,下次合并被 .pyc 改动挡住 | 已修:merge 前清 worktree 的 __pycache__/*.pyc,主仓的 .pyc 改动先还原;单测覆盖 pyc 不入库 | | 守护进程 + 健康检查 + 自动恢复 | host.py 跑完就退出,不常驻;state 坏了静默当空 state;中断任务一直停在 running | 已修:daemon(双 fork 到 PID 1,循环 recover + run-due)、health(不跑模型,查二进制/配置/state/server/daemon)、restore-state(坏了从 .bak 恢复并留底)、recover(超过阈值的 running 标 interrupted,可用 run 续跑,不自动重跑模型回合) | +| GitHub PR / CI 一条线 | 本地 merge 完就完,没有 PR、没有 CI 门 | 已修(代码 + 本地可测部分):github-workflow 生成 .github/workflows/host-verify.yml(checkout + python + unittest,幂等);pr(push 分支 + gh pr create,成功后 pr_url/pr_number 写回任务记录);pr-checks(解析 gh pr checks,all_pass 仅全 pass 为真,空 checks 不算过);pr-merge(先查 checks,未全过拒绝且不调 merge,全过才 gh pr merge --merge 并标 merged,不动本地主分支)。gh 调用走 run_gh(),单测用假 gh。**真实 PR 本轮没跑**:本仓没有 git remote,gh 未登录,见下“GitHub PR/CI 的外部条件”。 | | 边界:cwd、沙箱、审批、模型 | dispatch / turn 参数里定,见 `config.example.toml` | 已实测 | | 任务登记 | `host-state.json`:task -> thread/worktree/状态/diff;agent 状态本身仍在 CODEX_HOME,不另存第二份 | 已实测 | @@ -197,6 +198,34 @@ turn 可以真正并发(不是轮流排队)。 或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 + 回收后重拉”的定位。 +## GitHub PR/CI 的外部条件(真实 PR 仍缺这两样) + +PR/CI 这条线本轮只做了代码和本地可测部分,真实 PR 没有跑, +原因如实写在这里,不是没做完就含糊过去: + +- **仓库远端**:本仓(codex-app-server)目前没有 git remote + origin,`git remote -v` 为空。pr 命令会先查 remote,没有就 + 明确报错,不静默失败。以后给仓库加 remote 再跑: + `git remote add origin `。 +- **gh 登录**:本机有 `gh`,但 `gh auth status` 显示未登录。 + 不要在本轮登录,也不要把 PAT 写进任何文件、state、日志或 + 聊天记录;以后用户给 PAT 时走 Secure Vault,再跑 + `gh auth login`。没有登录时 pr/pr-checks/pr-merge 会把 gh + 的错误原文打印出来,不编造 PR URL。 +- **流程(远端和登录齐了之后)**: + 1. `host.py github-workflow` 生成 CI workflow(已生成的文件 + 要提交进仓库,GitHub Actions 才会跑它); + 2. 任务 completed 后 `host.py pr --task <名>`:push 分支 + + gh pr create,pr_url/pr_number 写回任务记录; + 3. `host.py pr-checks --task <名>`:看 CI 全过没有; + 4. `host.py pr-merge --task <名>`:全过才合并,合并由 + GitHub 完成,不动本地主分支——和本地 `merge` 命令是两条 + 线,不要混用:本地 merge 是直接合进本地主仓,PR merge + 是走 GitHub 的 PR 流程。 +- **两条线不冲突的原因**:本地 merge 用 merge_task()(自动验测 + + 回滚),PR merge 用 pr-merge(checks 门 + GitHub 合并); + 一个任务要么走本地 merge,要么走 PR,不要两边都合。 + ## 开源没给到的(别指望照搬) - 模型本身、ChatGPT 登录、计费、Codex Cloud 云端沙箱。 diff --git a/TEST-REPORT.md b/TEST-REPORT.md index bfe8b2c..f3fd274 100644 --- a/TEST-REPORT.md +++ b/TEST-REPORT.md @@ -438,3 +438,50 @@ tests/ 标准库 unittest,验收命令就是 `python3 -m unittest discover -s `host.py --config health`(无模型成本)和 / 或 `run-due`(到期才跑模型),需用户定间隔和是否允许 自动跑模型后才装。 + +## GitHub PR/CI 一条线(2026-10-26,用户选“先做 GitHub PR/CI 这一条线”) + +- **只做代码和本地可测部分,真实 PR 本轮没跑**:本仓没有 + git remote origin(`git remote -v` 为空),gh 未登录 + (`gh auth status`:未登录)。按任务要求,不登录 GitHub、 + 不向真实 GitHub push 或建 PR,真实 PR 那一步留给用户以后 + 给仓库远端和 PAT 之后再跑;PAT 走 Secure Vault,绝不写进 + 文件、state、日志或聊天记录。 +- **新增命令(都在 Server 创建之前,不跑模型)**: + - `github-workflow`:生成 `.github/workflows/host-verify.yml` + (checkout + setup-python 3.12 + `python3 -m unittest + discover -s tests -v`),幂等:已存在且内容相同则不改写。 + 临时仓库实测:第一次 changed=true,第二次 changed=false。 + - `pr --task <名>`:校验任务(无 branch 或状态不是 + completed/merged 就报错退出)→ 查 remote origin(无 remote + 就明确报错,不静默失败)→ `git push origin ` → + `gh pr create --title/--body/--head`(body 含任务名、提示摘要、 + 未解决评审评论)→ 成功后 pr_url/pr_number 写回任务记录并 + 保存 state;失败打印 gh/git 错误原文,不编造 URL。 + - `pr-checks --task <名>`:调 `gh pr checks `,解析 + 输出返回 {pr_url, checks: [{name, state}], all_pass}, + all_pass 仅当 checks 非空且全部为 pass 时为真(空 checks + 不算过,否则空 PR 也能合)。解析兼容制表符格式和 + 人类可读格式(✓ build pass / ✗ test fail)。 + - `pr-merge --task <名>`:先调 pr-checks,未全过拒绝且不调 + merge,并打印未过项;全过才 `gh pr merge --merge`,成功 + 后任务状态标 merged。**不动本地主分支**:合并由 GitHub + 完成,和本地 merge_task()(直接合进本地主仓 + 自动验测 + 回滚)是两条线,一个任务不要两边都合。 +- **gh 可注入**:全部 gh 调用走 `run_gh(args, cwd, runner)`, + 单测注入假 gh,不依赖真实网络和登录;真实路径走 gh CLI。 +- **单测**:tests 从 19 个涨到 27 个,全部通过。新增 8 个: + workflow 内容正确且幂等(含被改坏后恢复);pr-checks + 解析 pass / fail / mixed / 空;pr-merge 未全过时拒绝且未 + 调 merge、全过时才调 merge 并标 merged;无 branch / + 非 completed 状态被拒;无 remote 时 pr 报错含 remote; + 本地 bare 远端 push 成功后假 gh 返回 PR URL(pr_number + 42 写回任务,bare 远端里确有 host/t1 分支);CLI 层 + `pr --task` 在无 remote 时退出码非零且输出含 remote。 +- **未动的部分**:本地 merge/goal/review 行为未改;没有动 + ~/.codex/config.toml;没有动 wechat-bridge;没有提交 + git commit,改动留在工作区。 +- **真实 PR 仍缺什么**:仓库远端(`git remote add origin `) + 和 gh 登录(PAT 走 Secure Vault)。两者齐了之后的顺序: + github-workflow 生成并提交 workflow → 任务 completed 后 + pr → pr-checks → pr-merge。 diff --git a/host.py b/host.py index dcad452..b51a8d6 100644 --- a/host.py +++ b/host.py @@ -47,6 +47,7 @@ import argparse import json import os import queue +import re import subprocess import sys import threading @@ -919,6 +920,189 @@ def check_state(state_path, state): return out +# ---------------- GitHub PR / CI ---------------- +# 这一层只做 host -> GitHub 的桥,不做本地合并:本地合并仍是 +# merge_task(),PR 合并由 GitHub 完成,两条线互不干扰。 +# 真实 PR 需要两样外部条件,缺一不可,且都不由本轮代码提供: +# 1. 仓库有 git remote origin(本仓目前没有 remote); +# 2. gh 已登录(gh auth login,PAT 走 Secure Vault, +# 绝不写进文件、state、日志或聊天记录)。 +# gh 调用全部走 run_gh(),单测注入假 gh,不依赖网络和登录。 + +HOST_VERIFY_WORKFLOW = """name: host-verify +on: + pull_request: + push: + branches: [main] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 -m unittest discover -s tests -v +""" + + +def generate_github_workflow(repo): + """生成 .github/workflows/host-verify.yml(幂等): + 返回 (changed, path)。文件已存在且内容相同则不改写。""" + path = os.path.join(repo, ".github", "workflows", "host-verify.yml") + os.makedirs(os.path.dirname(path), exist_ok=True) + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + if f.read() == HOST_VERIFY_WORKFLOW: + return False, path + with open(path, "w", encoding="utf-8") as f: + f.write(HOST_VERIFY_WORKFLOW) + return True, path + + +def run_gh(args, cwd=None, runner=None): + """gh 调用唯一入口。runner 供单测注入:runner(args) 返回 + 一个带 returncode/stdout/stderr 的对象;真实路径走 gh CLI, + cwd 传给 gh 找仓库上下文(假 runner 忽略 cwd)。""" + if runner is not None: + return runner(args) + return subprocess.run(["gh"] + list(args), cwd=cwd, + capture_output=True, text=True) + + +def _github_remote_url(repo): + r = git(["remote", "get-url", "origin"], repo) + url = (r.stdout or "").strip() + return url if r.returncode == 0 and url else None + + +def build_pr_body(task): + """PR 正文:任务名、提示摘要、未解决评审评论。 + 只含任务本身的信息,不含密钥。""" + prompt = (task.get("prompt") or "")[:500] + lines = [f"host 任务:{task.get('name')}", + "", + "任务提示摘要:", + prompt or "(无提示)", + ""] + comments = open_review_comments(task) + if comments: + lines.append("评审评论(未解决):") + for c in comments: + lines.append(f"- {c['file']}:{c['line']}: {c['comment']}") + else: + lines.append("评审评论:无未解决评论") + return "\n".join(lines) + + +def create_pr(repo, task, gh_runner=None): + """建 PR:先校验任务,再 push 分支,再 gh pr create。 + 成功返回 {pr_url, pr_number, branch} 并写回任务记录; + 任何一步失败都抛异常,不编造 URL、不静默失败。 + 调用方负责 save_state。""" + branch = task.get("branch") + if not branch: + raise ValueError(f"任务 {task.get('name')} 没有 branch," + f"无法建 PR(可能是没用 worktree 派发的任务)") + if task.get("status") not in ("completed", "merged"): + raise ValueError(f"任务 {task.get('name')} 状态是 " + f"{task.get('status')},只有 completed/merged " + f"才能建 PR") + if not _github_remote_url(repo): + raise RuntimeError( + "仓库没有 git remote origin,无法 push 分支和建 PR:" + "请先给仓库加 remote(git remote add origin )" + "并用 gh auth login 登录,PAT 走 Secure Vault," + "不要写进文件或聊天记录") + push = git(["push", "origin", branch], repo) + if push.returncode != 0: + raise RuntimeError(f"git push origin {branch} 失败:" + f"{push.stderr or push.stdout}") + title = f"host: {task.get('name')}" + r = run_gh(["pr", "create", "--title", title, + "--body", build_pr_body(task), "--head", branch], + cwd=repo, runner=gh_runner) + out = (r.stdout or "").strip() + if r.returncode != 0 or not out: + # 失败时打印 gh 的错误原文,不编造 URL。 + raise RuntimeError(f"gh pr create 失败:{r.stderr or out}") + pr_url = out.splitlines()[0].strip() + m = re.search(r"/(\d+)\s*$", pr_url) + pr_number = int(m.group(1)) if m else None + task["pr_url"] = pr_url + task["pr_number"] = pr_number + return {"pr_url": pr_url, "pr_number": pr_number, "branch": branch} + + +def parse_pr_checks(output): + """解析 gh pr checks 输出,兼容两种常见格式: + 制表符格式(namestate…)和人类可读格式(✓ build pass)。 + 返回 (checks, all_pass):checks 为 [{name, state}],state + 统一成 pass/fail/pending/skipped/cancelled/neutral; + 没有任何 check 时 all_pass 为 False(空 PR 不能算全过)。""" + checks = [] + for raw in (output or "").splitlines(): + line = raw.strip() + if not line or "no checks" in line.lower(): + continue + if line.lower().startswith("name"): + continue + if "\t" in line: + parts = [p.strip() for p in line.split("\t") if p.strip()] + if len(parts) < 2: + continue + name, state_raw = parts[0], parts[1].lower() + else: + m = re.search(r"\b(pass|passing|fail|failing|failed|pending|" + r"queued|waiting|in_progress|skipped|cancelled|" + r"neutral)\b", line.lower()) + if not m: + continue + state_raw = m.group(1) + name = line[:m.start()].strip(" ✓✗●○-").strip() + if not name: + name = line + state = {"passing": "pass", "failing": "fail", "failed": "fail", + "queued": "pending", "waiting": "pending", + "in_progress": "pending"}.get(state_raw, state_raw) + checks.append({"name": name, "state": state}) + all_pass = bool(checks) and all(c["state"] == "pass" for c in checks) + return checks, all_pass + + +def get_pr_checks(task, gh_runner=None, repo=None): + """查 PR 的 CI 状态。任务须已有 pr_number,否则报错。""" + number = task.get("pr_number") + if not number: + raise ValueError(f"任务 {task.get('name')} 还没有 PR 编号," + f"先跑 pr 建 PR") + r = run_gh(["pr", "checks", str(number)], cwd=repo, runner=gh_runner) + if r.returncode != 0: + raise RuntimeError(f"gh pr checks 失败:{r.stderr or r.stdout}") + checks, all_pass = parse_pr_checks(r.stdout or "") + return {"pr_url": task.get("pr_url"), "checks": checks, + "all_pass": all_pass} + + +def merge_pr(task, gh_runner=None, repo=None): + """PR 合并门:先查 checks,未全过拒绝且不调 merge; + 全过后调 gh pr merge --merge,成功才把任务标 merged。 + 不动本地主分支——合并由 GitHub 完成,本地分支/主仓 + 不由这条命令改(和本地 merge_task() 是两条线)。""" + info = get_pr_checks(task, gh_runner=gh_runner, repo=repo) + if not info["all_pass"]: + bad = [c for c in info["checks"] if c["state"] != "pass"] + return False, (f"PR checks 未全过,拒绝合并:" + f"{[(c['name'], c['state']) for c in bad]}") + r = run_gh(["pr", "merge", str(task["pr_number"]), "--merge"], + cwd=repo, runner=gh_runner) + if r.returncode != 0: + return False, f"gh pr merge 失败:{r.stderr or r.stdout}" + task["status"] = "merged" + return True, f"PR #{task['pr_number']} 已合并:{task.get('pr_url')}" + + + def copy_tree_excluding_git(src, dst): """把 src 工作区的文件拷到 dst(不含 .git、__pycache__、.pyc), @@ -1143,6 +1327,13 @@ def main(): help="把 run-due 写进当前用户 crontab(幂等,已有则不重复)") p.add_argument("--cron-every-minutes", type=int, default=30) sub.add_parser("run-due") + sub.add_parser("github-workflow") + p = sub.add_parser("pr"); p.add_argument("--task", required=True) + p.add_argument("--repo", default=None) + p = sub.add_parser("pr-checks"); p.add_argument("--task", required=True) + p.add_argument("--repo", default=None) + p = sub.add_parser("pr-merge"); p.add_argument("--task", required=True) + p.add_argument("--repo", default=None) args = ap.parse_args() # 配置解析:CLI > 配置文件 > 内置默认。配置文件里绝不放密钥, @@ -1235,6 +1426,49 @@ def main(): save_state(args.state, state) print(json.dumps(c, indent=2, ensure_ascii=False)) return + if args.cmd == "github-workflow": + repo = cfg.get("repo") + if not repo: + print("缺少 repo:请在配置里配 repo") + raise SystemExit(1) + changed, path = generate_github_workflow(repo) + print(json.dumps({"path": path, + "changed": changed}, indent=2, ensure_ascii=False)) + return + if args.cmd in ("pr", "pr-checks", "pr-merge"): + if args.task not in state["tasks"]: + print(f"任务不存在:{args.task}") + raise SystemExit(1) + t = state["tasks"][args.task] + repo = args.repo or t.get("repo") or cfg.get("repo") + try: + if args.cmd == "pr": + if not repo: + print("缺少 repo:任务和配置里都没有 repo") + raise SystemExit(1) + info = create_pr(repo, t) + save_state(args.state, state) + print(json.dumps(info, indent=2, ensure_ascii=False)) + elif args.cmd == "pr-checks": + info = get_pr_checks(t, repo=repo) + print(json.dumps(info, indent=2, ensure_ascii=False)) + else: + ok, msg = merge_pr(t, repo=repo) + if ok: + save_state(args.state, state) + print(json.dumps({"merged": True, "message": msg}, + indent=2, ensure_ascii=False)) + else: + print(msg) + raise SystemExit(1) + except SystemExit: + raise + except Exception as e: + # 失败时打印错误原文(gh/git 的 stderr 已包含在异常里), + # 不编造 PR URL,不静默失败。 + print(str(e)) + raise SystemExit(1) + return if args.cmd == "health": report = run_health_check(args.state, cfg, args.codex_bin or default_codex_bin(), args.codex_home, args.approval) diff --git a/tests/test_host.py b/tests/test_host.py index ead48f8..844f8f4 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -373,6 +373,154 @@ class HealthRestoreRecoverTest(unittest.TestCase): self.assertTrue(report["checks"]["server_ok"]) +class GithubPRTest(unittest.TestCase): + class FakeGh: + def __init__(self, checks_output="", pr_url=""): + self.checks_output = checks_output + self.pr_url = pr_url + self.calls = [] + + def __call__(self, args): + self.calls.append(list(args)) + import types + if args[:2] == ["pr", "checks"]: + return types.SimpleNamespace(returncode=0, + stdout=self.checks_output, + stderr="") + if args[:2] == ["pr", "create"]: + return types.SimpleNamespace(returncode=0, + stdout=self.pr_url + "\n", + stderr="") + if args[:2] == ["pr", "merge"]: + return types.SimpleNamespace(returncode=0, + stdout="Merged\n", stderr="") + return types.SimpleNamespace(returncode=1, stdout="", + stderr="unknown fake gh call") + + def test_workflow_content_and_idempotent(self): + with tempfile.TemporaryDirectory() as tmp: + changed, path = host.generate_github_workflow(tmp) + self.assertTrue(changed) + content = Path(path).read_text() + self.assertIn("actions/checkout", content) + self.assertIn("setup-python", content) + self.assertIn("python3 -m unittest discover -s tests -v", + content) + changed2, _ = host.generate_github_workflow(tmp) + self.assertFalse(changed2) + # 内容被改坏后再次生成应恢复 + Path(path).write_text("garbage\n") + changed3, _ = host.generate_github_workflow(tmp) + self.assertTrue(changed3) + self.assertEqual(Path(path).read_text(), content) + + def test_pr_checks_parse_pass_fail_mixed(self): + checks, all_pass = host.parse_pr_checks( + "build\tpass\t1m\ntest\tpass\t2m\n") + self.assertTrue(all_pass) + self.assertEqual([c["state"] for c in checks], ["pass", "pass"]) + checks, all_pass = host.parse_pr_checks( + "✓ build pass\n✗ test fail\n") + self.assertFalse(all_pass) + self.assertEqual([c["name"] for c in checks], ["build", "test"]) + checks, all_pass = host.parse_pr_checks("") + self.assertFalse(all_pass) + self.assertEqual(checks, []) + + def test_pr_merge_refuses_when_not_all_pass(self): + fake = self.FakeGh(checks_output="build\tpass\ntest\tfail\n") + task = {"name": "t", "pr_url": "https://example.com/pull/7", + "pr_number": 7, "status": "completed"} + ok, msg = host.merge_pr(task, gh_runner=fake) + self.assertFalse(ok) + self.assertIn("未全过", msg) + self.assertNotIn(["pr", "merge", "7", "--merge"], fake.calls) + self.assertEqual(task["status"], "completed") + + def test_pr_merge_only_when_all_pass(self): + fake = self.FakeGh(checks_output="build\tpass\ntest\tpass\n") + task = {"name": "t", "pr_url": "https://example.com/pull/8", + "pr_number": 8, "status": "completed"} + ok, msg = host.merge_pr(task, gh_runner=fake) + self.assertTrue(ok, msg) + self.assertIn(["pr", "merge", "8", "--merge"], fake.calls) + self.assertEqual(task["status"], "merged") + + def test_pr_without_branch_or_wrong_status_rejected(self): + with self.assertRaises(ValueError): + host.create_pr("/tmp", {"name": "t", "status": "completed"}) + with self.assertRaises(ValueError): + host.create_pr("/tmp", {"name": "t", "branch": "host/t", + "status": "running"}) + + def test_pr_without_remote_gives_clear_error(self): + with tempfile.TemporaryDirectory() as tmp: + _init_git_repo(tmp) + Path(tmp, "f.txt").write_text("x\n") + subprocess.run(["git", "add", "-A"], cwd=tmp, check=True) + subprocess.run(["git", "commit", "-qm", "initial"], + cwd=tmp, check=True) + task = {"name": "t", "branch": "host/t", "status": "completed", + "prompt": "do it"} + with self.assertRaises(RuntimeError) as ctx: + host.create_pr(tmp, task, gh_runner=self.FakeGh()) + self.assertIn("remote", str(ctx.exception)) + + def test_pr_push_to_local_bare_remote_then_fake_gh(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "repo") + os.makedirs(repo) + _init_git_repo(repo) + Path(repo, "f.txt").write_text("x\n") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "initial"], + cwd=repo, check=True) + subprocess.run(["git", "checkout", "-qb", "host/t1"], + cwd=repo, check=True) + Path(repo, "f.txt").write_text("y\n") + subprocess.run(["git", "commit", "-qam", "change"], + cwd=repo, check=True) + remote = os.path.join(tmp, "remote.git") + subprocess.run(["git", "init", "--bare", "-q", remote], + check=True) + subprocess.run(["git", "remote", "add", "origin", remote], + cwd=repo, check=True) + fake = self.FakeGh( + pr_url="https://github.com/example/repo/pull/42") + task = {"name": "t1", "branch": "host/t1", + "status": "completed", "prompt": "change f"} + info = host.create_pr(repo, task, gh_runner=fake) + self.assertEqual(info["pr_number"], 42) + self.assertEqual(task["pr_url"], + "https://github.com/example/repo/pull/42") + self.assertEqual(task["pr_number"], 42) + branches = subprocess.run( + ["git", "--git-dir", remote, "branch"], capture_output=True, + text=True).stdout + self.assertIn("host/t1", branches) + + def test_pr_cli_without_remote_fails_with_clear_error(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "repo") + os.makedirs(repo) + _init_git_repo(repo) + state_path = os.path.join(tmp, "host-state.json") + with open(state_path, "w") as f: + json.dump({"tasks": {"t1": { + "name": "t1", "branch": "host/t1", + "status": "completed", "prompt": "do it", + "repo": repo}}, "automations": []}, f) + cfg_path = os.path.join(tmp, "cfg.json") + with open(cfg_path, "w") as f: + json.dump({"repo": repo, "state": state_path}, f) + r = subprocess.run( + [sys.executable, str(REPO_ROOT / "host.py"), + "--config", cfg_path, "pr", "--task", "t1"], + capture_output=True, text=True) + self.assertNotEqual(r.returncode, 0) + self.assertIn("remote", r.stdout + r.stderr) + + if __name__ == "__main__": unittest.main()