taskops-cli 0.5.0__py3-none-any.whl → 0.5.2__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.
taskops/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  __title__ = "taskops"
4
- __version__ = "0.5.0" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.5.2" # single source of truth; pyproject reads it dynamically
@@ -0,0 +1,198 @@
1
+ """`taskops remote git` — this checkout's git, pointed at the board's own repo.
2
+
3
+ taskops remote git print the address and the two lines to paste
4
+ taskops remote git --add wire them here (remote `taskops`, no origin touched)
5
+ taskops remote git <host>/<board> another board's address, explicitly
6
+
7
+ §16, "The host becomes the remote", left one thing on the DEV's side unsaid: the
8
+ host serves `https://<host>/<board>/repo.git` and nothing in the CLI spelled that
9
+ address out loud, so the only way to reach it was to read `http/gitpack.py`. This
10
+ command is that sentence, and it is the same act as `taskops remote add` —
11
+ recording an address — so it stays on that verb rather than becoming a twelfth
12
+ command.
13
+
14
+ **`origin` is never written, and `--name origin` is REFUSED** — not "left alone
15
+ if it exists": refused outright. A checkout's `origin` is somebody's working
16
+ setup, very often their GitHub, and no state of it is this command's to decide,
17
+ its absence included. So the remote is `taskops`, `--name <other>` when even
18
+ that is taken, and a name in use is a refusal naming that flag instead of a
19
+ `set-url`. Adding a remote is CONNECTING; repointing an `origin` is managing.
20
+
21
+ **The credential is a HELPER, never a URL.** The obvious spelling —
22
+ `https://x:<token>@host/<board>/repo.git` — is refused here, and not on taste:
23
+ `git remote add` writes its URL into `.git/config`, plaintext with the repo's
24
+ own permissions, so that spelling persists a live session token in the one file
25
+ nobody thinks to look at. It is also WRONG within the hour, because a taskops
26
+ session expires (`session.py`) and a token baked into a config cannot renew
27
+ itself. So what gets configured is `credential.<host>.helper`, and the helper is
28
+ this CLI: git asks for the password at push time, `credential()` below mints or
29
+ renews a session from the ssh key already on disk (`identity.establish`, the
30
+ same one every board verb uses), hands git the token on a pipe, and NOTHING is
31
+ written. One credential story, §19's, through git's own door.
32
+
33
+ **Where the address is SPELLED, and where it is not.** This command prints the
34
+ exact URL for one board; `board ls` prints the shape (`shape`, below). The board
35
+ PAYLOAD gains no `repo_url`: the server cannot know its own public address (a
36
+ proxy, a port-forward, the local `taskops ui` window — every reader reached it
37
+ at an address the server never saw), so a stored one rots first and sends a dev
38
+ to the wrong host. The address is (the host you asked) + `/<board>/repo.git`, a
39
+ derivation the CLIENT can always do right and the server never can.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import sys
45
+ import argparse
46
+ from pathlib import Path
47
+
48
+ from . import commands
49
+ from .. import identity
50
+ from ..board import find_root, read_config
51
+ from .remote import named, recorded_host
52
+ from .._errors import TaskopsError
53
+ from ..gitwork import run
54
+
55
+ REMOTE = "taskops"
56
+ """The name added by `--add` — never `origin`, and never a fork's `upstream`:
57
+ the two names git culture has already spent."""
58
+
59
+ USERNAME = "x"
60
+ """Basic wants a username and the token is the whole credential, so the field is
61
+ decoration — the same `x` `http/gitpack.py` documents on its side."""
62
+
63
+ SUFFIX = "repo.git" # `http/routes.py`'s own first segment, spelled once here
64
+
65
+ NOT_ORIGIN = (
66
+ "`origin` is git's and this command does not take it over — that remote is "
67
+ "somebody's own setup (very often their GitHub), and repointing it is not "
68
+ "connecting, it is rewriting. The default name works: taskops remote git --add"
69
+ )
70
+
71
+ TAKEN = (
72
+ "this checkout already has a remote called {name}, pointing at {url} — "
73
+ "`taskops remote git --add --name <other>` adds one under a name that is free. "
74
+ "Nothing here rewrites a remote you configured."
75
+ )
76
+
77
+ HELPER_TAKEN = (
78
+ "a credential helper for {host} is already configured here ({have}) — leave it, "
79
+ "or replace it yourself with `git config --local credential.{host}.helper …`. "
80
+ "This command does not overwrite a credential you set up."
81
+ )
82
+
83
+
84
+ def url_for(host: str, board: str) -> str:
85
+ """`https://<host>/<board>/repo.git` — spelled ONCE on the client side."""
86
+ return f"{host.rstrip('/')}/{board}/{SUFFIX}"
87
+
88
+
89
+ def shape(host: str) -> str:
90
+ """What `board ls` says about where the git lives — the SHAPE, on the line it
91
+ already prints, once. Not per row: the address is the same derivation for
92
+ every board on the host, and repeating it N times is noise, not discovery."""
93
+ return f" · git: {host.rstrip('/')}/<board>/{SUFFIX} (taskops remote git)"
94
+
95
+
96
+ def helper_command(python: str) -> str:
97
+ """What git runs to get the password. `!` is git's own marker for "this is a
98
+ shell command, not a `git-credential-<name>` on PATH", and the interpreter is
99
+ named absolutely for `gitwork/install.py`'s reason: the hooks git fires do not
100
+ inherit whatever virtualenv was active when this was configured."""
101
+ return f'!"{python}" -m taskops.cli hook credential'
102
+
103
+
104
+ def wire(args: argparse.Namespace, python: str) -> int:
105
+ """`taskops remote git [--add] [--name <n>]` — print it, or write it."""
106
+ root = find_root(Path.cwd())
107
+ host, board = named(str(args.url))
108
+ url, name = url_for(host, board), str(getattr(args, "name", "") or REMOTE)
109
+ helper = helper_command(python)
110
+ if name == "origin":
111
+ raise TaskopsError(NOT_ORIGIN)
112
+ if not bool(getattr(args, "add", False)):
113
+ print(url)
114
+ print(f" git remote add {name} {url}")
115
+ print(f" git config --local credential.{host}.helper '{helper}'")
116
+ print(f" git push {name} <branch> — your ssh key mints the token, nothing to type")
117
+ print(" or write both here: taskops remote git --add")
118
+ return 0
119
+ _remote(root, name, url)
120
+ _helper(root, host, helper)
121
+ print(f"{name} {url}")
122
+ print(f" credential.{host}.helper mints a session from your ssh key at push time")
123
+ print(f" no token is written anywhere — git push {name} <branch>")
124
+ return 0
125
+
126
+
127
+ def _remote(root: Path, name: str, url: str) -> None:
128
+ """Add it, or refuse. `git remote add` would refuse a duplicate itself, and
129
+ the refusal is intercepted here so it names the flag that gets past it."""
130
+ have = run.git("remote", "get-url", name, cwd=root)
131
+ if have.ok and have.out.strip():
132
+ if have.out.strip() == url:
133
+ return # already exactly this: re-running is free and says so
134
+ raise TaskopsError(TAKEN.format(name=name, url=have.out.strip()))
135
+ run.must("remote", "add", name, url, cwd=root, why=f"cannot add the remote {name}")
136
+
137
+
138
+ def _helper(root: Path, host: str, helper: str) -> None:
139
+ """Configure the helper for THIS host only: `credential.<url>` answers for
140
+ that URL and nothing else, which is exactly the scope of the token it hands
141
+ out (`session.py` mints per host)."""
142
+ have = run.git("config", "--local", "--get", f"credential.{host}.helper", cwd=root)
143
+ if have.ok and have.out.strip():
144
+ if have.out.strip() == helper:
145
+ return
146
+ raise TaskopsError(HELPER_TAKEN.format(host=host, have=have.out.strip()))
147
+ run.must(
148
+ "config", "--local", f"credential.{host}.helper", helper,
149
+ cwd=root, why=f"cannot configure the credential helper for {host}",
150
+ )
151
+
152
+
153
+ def credential(text: str, rest: list[str]) -> int:
154
+ """git's credential helper, answering `get` and only `get`.
155
+
156
+ Protocol (`gitcredentials(7)`): the operation arrives as argv, the request as
157
+ `key=value` lines on stdin, the answer as `key=value` lines on stdout. An
158
+ unanswered request is not an error — git moves to the next helper — so every
159
+ reason to decline is silence, plus a line on stderr when it is a FAILURE:
160
+ `hooks.py`'s policy, for its reason. `store` and `erase` are declined too: a
161
+ helper that caches is a helper that persists a token, and minting is free.
162
+
163
+ It also declines a host that is not the one this checkout operates — the
164
+ second wall, and the one that matters: a checkout whose recorded host changed
165
+ must not sign a challenge to the old one.
166
+ """
167
+ if (rest[0] if rest else "get") != "get":
168
+ return 0 # store / erase: nothing is cached, so there is nothing to do
169
+ asked = _fields(text)
170
+ wanted = f"{asked.get('protocol', '')}://{asked.get('host', '')}"
171
+ known = recorded_host()
172
+ if not known or wanted != known.rstrip("/"):
173
+ return 0
174
+ root = find_root(Path.cwd())
175
+ config = read_config(root)
176
+ try:
177
+ token, door = identity.establish(root, known, config, "", "", commands.principal())
178
+ except TaskopsError as err:
179
+ print(f"taskops: {err}", file=sys.stderr)
180
+ return 0
181
+ if door and identity.is_own_host(config, known):
182
+ identity.cache_login(root, door)
183
+ print(f"username={USERNAME}")
184
+ print(f"password={token}")
185
+ return 0
186
+
187
+
188
+ def _fields(text: str) -> dict[str, str]:
189
+ """git's request, as a dict. A blank line ends it; anything unparseable is
190
+ dropped rather than guessed at."""
191
+ out: dict[str, str] = {}
192
+ for line in text.splitlines():
193
+ if not line.strip():
194
+ break
195
+ key, sep, value = line.partition("=")
196
+ if sep:
197
+ out[key.strip()] = value.strip()
198
+ return out
taskops/cli/main.py CHANGED
@@ -3,6 +3,8 @@
3
3
  taskops init a local board in this repo
4
4
  taskops join <url> join one (bare, ?token= or --invite), install the hooks
5
5
  taskops remote add <url> the host this checkout operates, like git's origin
6
+ taskops remote git the board's OWN git repository, and how to push to it:
7
+ printed to paste, or --add to wire it here (never origin)
6
8
  taskops serve host boards — an events API, no dashboard
7
9
  taskops server init bootstrap THIS host: its owner and their ssh key
8
10
  taskops board create make a board on a host · board ls, from anywhere
@@ -20,7 +22,8 @@
20
22
  taskops invite <who> a single-use link · taskops revoke --key|--invite
21
23
  taskops tidy remove worktrees whose work is already in the trunk
22
24
  taskops ui the dashboard — serves it if nothing is, opens the browser
23
- taskops hook … what the two git hooks and the Claude hook call
25
+ taskops hook … what the git hooks, the Claude hook and git's credential
26
+ helper call — never a human
24
27
 
25
28
  Moving a card from the terminal does not exist: that is MCP. v1 grew 35
26
29
  management commands, each one a second way to do something the tools already
@@ -47,6 +50,7 @@ from . import (
47
50
  operate,
48
51
  serving,
49
52
  commands,
53
+ gitremote,
50
54
  )
51
55
  from ..board import find_root
52
56
  from .._errors import TaskopsError
@@ -108,6 +112,11 @@ def _run(args: argparse.Namespace) -> int:
108
112
  return 0
109
113
  if args.command == "ui":
110
114
  return serving.ui(here)
115
+ if str(args.which) == "credential":
116
+ # git's credential helper protocol: the operation is argv, the request is
117
+ # stdin. Read here, so `gitremote.credential` stays a pure function of
118
+ # what git said and the tests can hand it a request directly.
119
+ return gitremote.credential(sys.stdin.read(), [str(x) for x in args.rest])
111
120
  if str(args.which) == "claude":
112
121
  # Routed here and not through `commands` so the delivery hook owns its
113
122
  # own error policy end to end: it prints NOTHING, ever, including the
taskops/cli/operate.py CHANGED
@@ -35,7 +35,7 @@ import argparse
35
35
  from typing import Any
36
36
  from pathlib import Path
37
37
 
38
- from . import team, commands
38
+ from . import team, commands, gitremote
39
39
  from .. import _wire, _clock, identity
40
40
  from .._json import as_object
41
41
  from ..board import find_root, read_config
@@ -63,7 +63,7 @@ def board(args: argparse.Namespace) -> int:
63
63
  host, _ = address(target)
64
64
  answer = call(host, "board.list", {}, signed_in(host, args))
65
65
  rows: list[dict[str, Any]] = [as_object(row) for row in answer.get("boards", [])]
66
- print(f"{host} — {len(rows)} board(s), as {answer.get('role', '?')}")
66
+ print(f"{host} — {len(rows)} board(s), as {answer.get('role', '?')}{gitremote.shape(host)}")
67
67
  for row in rows:
68
68
  print(f" {row['name']:<24} {row['cards']:>4} cards seq {row['seq']:<6} {_ago(row)}")
69
69
  return 0
taskops/cli/parser.py CHANGED
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  import argparse
19
19
 
20
+ from . import gitremote
20
21
  from ..core import forge
21
22
 
22
23
  AS_HELP = "the principal that key belongs to (default: $USER)"
@@ -28,11 +29,23 @@ def build(description: str) -> argparse.ArgumentParser:
28
29
  sub.add_parser("init", help="a local board in this repo")
29
30
  _join(sub)
30
31
  origin = sub.add_parser("remote", help="the host this checkout operates (git's origin)")
31
- origin.add_argument("action", nargs="?", default="", choices=["", "add"])
32
- origin.add_argument("url", nargs="?", default="", help="https://<host>")
32
+ origin.add_argument("action", nargs="?", default="", choices=["", "add", "git"])
33
+ # `add` takes the HOST; `git` takes an optional <host>/<board> and otherwise
34
+ # uses the recorded pair — one slot, because both are an address.
35
+ origin.add_argument("url", nargs="?", default="", help="add: https://<host> · git: <host>/<board>")
33
36
  origin.add_argument(
34
37
  "--replace", action="store_true", help="this checkout already names another host"
35
38
  )
39
+ origin.add_argument(
40
+ "--add",
41
+ action="store_true",
42
+ help="remote git: write the remote and the credential helper here instead of printing them",
43
+ )
44
+ origin.add_argument(
45
+ "--name",
46
+ default="",
47
+ help=f"remote git --add: the remote's name (default: {gitremote.REMOTE}; never origin)",
48
+ )
36
49
  server = sub.add_parser("serve", help="host boards")
37
50
  server.add_argument("--root", default="~/taskops-boards")
38
51
  server.add_argument("--host", default="127.0.0.1")
@@ -48,7 +61,9 @@ def build(description: str) -> argparse.ArgumentParser:
48
61
  tidy.add_argument("--trunk", default="")
49
62
  sub.add_parser("ui", help="the dashboard: serve if needed, open the browser, token included")
50
63
  hook = sub.add_parser("hook", help="internal: what the installed hooks call")
51
- hook.add_argument("which", choices=["trailer", "commit", "claude"])
64
+ # `credential` is git's credential helper (`cli/gitremote.py`) — internal in
65
+ # exactly the same sense as the other three: git invokes it, never a human.
66
+ hook.add_argument("which", choices=["trailer", "commit", "claude", "credential"])
52
67
  hook.add_argument("rest", nargs="*")
53
68
  return parser
54
69
 
taskops/cli/remote.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  taskops remote add https://taskops.example.com record it, like git's origin
4
4
  taskops remote print what is recorded
5
+ taskops remote git [--add] the board's own git repository
5
6
 
6
7
  Git asks for neither a URL nor an identity file on every push, and the two
7
8
  reasons it does not are both copied here: the address is recorded per CLONE
@@ -27,6 +28,7 @@ already chosen.
27
28
 
28
29
  from __future__ import annotations
29
30
 
31
+ import sys
30
32
  import argparse
31
33
  from pathlib import Path
32
34
 
@@ -57,8 +59,16 @@ ALREADY = (
57
59
 
58
60
 
59
61
  def remote(args: argparse.Namespace) -> int:
60
- """`add` records the host; no argument prints it, `git remote -v` style."""
62
+ """`add` records the host; no argument prints it, `git remote -v` style.
63
+
64
+ `git` is the third action and it belongs here rather than on a command of its
65
+ own: recording where the board's git lives is the same act as recording where
66
+ the board lives (`cli/gitremote.py` argues the whole of it)."""
67
+ from . import gitremote
68
+
61
69
  root = find_root(Path.cwd())
70
+ if str(args.action) == "git":
71
+ return gitremote.wire(args, sys.executable)
62
72
  url, known = str(args.url), recorded_host()
63
73
  if str(args.action) != "add":
64
74
  print(f"origin {known}" if known else NO_REMOTE)
taskops/core/forge.py CHANGED
@@ -54,6 +54,16 @@ def is_repo(repo: str) -> bool:
54
54
  return len(parts) == 2 and all(parts) and not any(ch.isspace() for ch in repo)
55
55
 
56
56
 
57
+ def label(fact: dict[str, Any]) -> str:
58
+ """`host/owner/name` — the ONE spelling of a declared forge for a reader.
59
+
60
+ It is a key as well as a caption: `store/mirroring.py` rows the outbound
61
+ mirror's last word under it, so a board pointed at a new repo starts with
62
+ nothing said about it rather than inheriting the old one's report.
63
+ """
64
+ return f"{fact['host']}/{fact['repo']}"
65
+
66
+
57
67
  def declare(host: str, repo: str, need: str) -> dict[str, Any]:
58
68
  """A human's three words → the stored fact. Each refusal names the way out."""
59
69
  if host not in FORGES:
@@ -0,0 +1,122 @@
1
+ """The board's OWN bare repository — `<root>/<board>/repo.git` (§16, "The host
2
+ becomes the remote"). This is what the smart-HTTP door serves and what the
3
+ JSON diff door reads once it exists: truth the forge does not necessarily
4
+ hold, deletable by nobody.
5
+
6
+ **Created on demand by the first WRITE-credentialed request, never by a
7
+ read.** `mounts.stores` carries the post-mortem this rule copies: a GET for a
8
+ name nobody had heard of used to leave a board directory on disk — a write
9
+ caused by a stranger's question. `repo.git` is truth, not cache, so the same
10
+ rule holds one level down: an anonymous clone of a board nobody has pushed to
11
+ answers 404 with the fact named, and the directory appears only when an
12
+ enrolled principal's push (or its ref advertisement, which git sends first)
13
+ asks for it. `http/gitpack.py` is the one caller and passes which case it is.
14
+
15
+ **It also owns the RETIREMENT of the pull mirror** (`adopt`, below): a board
16
+ whose only git is a `mirror.git` from §16's first amendment is migrated here,
17
+ once, and the mirror directory removed. That lives in this module rather than in
18
+ `mirror.py` because `mirror.py` is deleted — the thing owed to a retired
19
+ mechanism is the history it holds, and the module that keeps histories is this
20
+ one.
21
+
22
+ **Never pruned, enforced in the repo's own config.** `receive.denyDeletes`
23
+ and `receive.denyNonFastForwards` are written at creation, so the wall is
24
+ git's own, checked inside the very `git receive-pack` process that would
25
+ otherwise move the ref — there is no window between an application-level
26
+ check and the update, no pre-receive hook to install (a hook is a script on
27
+ disk that a later `git init` or a copy silently drops; config survives both),
28
+ and a client's `--force` changes nothing because the refusal is the server
29
+ process's. There is no flag that lifts either, for `board rm`'s reason (§11):
30
+ what a force would erase here is the diff of a landed card — the board's own
31
+ record. History rewriting, when it is ever needed, is the owner's deliberate
32
+ act against the host's filesystem, not a verb.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from shutil import rmtree
38
+ from pathlib import Path
39
+
40
+ from . import run
41
+
42
+ REPO = "repo.git"
43
+
44
+ CLONE_TIMEOUT = 120.0
45
+ """A seeding clone moves a whole history, once per board, from one local
46
+ directory to another beside it — no network in it at all, so the number is
47
+ patience against a very large repo and never a network budget."""
48
+
49
+
50
+ def at(board_dir: Path) -> Path | None:
51
+ """The board's repo if somebody has created it — never creates one."""
52
+ repo = board_dir / REPO
53
+ return repo if (repo / "HEAD").is_file() else None
54
+
55
+
56
+ def ensure(board_dir: Path) -> Path:
57
+ """Create the bare repo, refusal config included. Safe to run twice: `git
58
+ init` on an existing repository re-reads it and moves nothing, and setting
59
+ the same config value again is idempotent — so two racing first pushes both
60
+ land on the same repo instead of needing a lock here."""
61
+ repo = board_dir / REPO
62
+ run.must(
63
+ "init", "--bare", "--initial-branch=master", str(repo),
64
+ why=f"cannot create the board's repository at {repo}",
65
+ )
66
+ run.must("config", "receive.denyDeletes", "true", cwd=repo)
67
+ run.must("config", "receive.denyNonFastForwards", "true", cwd=repo)
68
+ return repo
69
+
70
+
71
+ LEGACY_MIRROR = "mirror.git"
72
+ """What §16's FIRST amendment left on disk — `<root>/<board>/mirror.git`, the
73
+ bare read-only clone of the declared forge that used to be the host's only git.
74
+ The name is kept here, in the module that replaced it, and nowhere else: the
75
+ mirror is retired, and the one thing still owed to it is the history it holds.
76
+ """
77
+
78
+
79
+ def adopt(board_dir: Path) -> Path | None:
80
+ """The board's repo, seeding it from a retired `mirror.git` if that is all
81
+ this board has. Never creates an EMPTY repo — that is `ensure`'s job, and
82
+ only a write-credentialed push may ask for it.
83
+
84
+ §16's "On-disk" paragraph, implemented: production hosts carry a populated
85
+ `mirror.git` today, and the reversal must not lose the history in it. So a
86
+ board with a mirror and no `repo.git` gets one local `git clone --bare
87
+ mirror.git repo.git` — on-disk, no network, cheap — and the mirror
88
+ directory is then REMOVED, which is the whole point of not promoting it in
89
+ place: `--mirror`'s fetch refspec means *make local match the forge,
90
+ prunes included*, and a truth-holder configured to erase itself is not a
91
+ truth-holder. `clone --bare` copies the refs and configures none of that.
92
+
93
+ It runs on the READ path (`http/repos.py`), which is the one place that
94
+ breaks the "a read never writes" rule on purpose and only here: this is a
95
+ MIGRATION of a history the host already possesses, not a directory
96
+ conjured by a stranger's question — nothing is created for a board that
97
+ holds neither repo, so an unknown name still leaves the disk untouched. It
98
+ happens at most once per board, ever, because the mirror is gone after it.
99
+
100
+ A failed clone leaves the mirror alone and answers None: a migration that
101
+ half-ran and then deleted its source would be the one unrecoverable
102
+ outcome here.
103
+ """
104
+ found = at(board_dir)
105
+ if found is not None:
106
+ return found
107
+ legacy = board_dir / LEGACY_MIRROR
108
+ if not (legacy / "HEAD").is_file():
109
+ return None
110
+ repo = board_dir / REPO
111
+ result = run.git("clone", "--bare", str(legacy), str(repo), timeout=CLONE_TIMEOUT)
112
+ if not result.ok or at(board_dir) is None:
113
+ return None
114
+ run.must("config", "receive.denyDeletes", "true", cwd=repo)
115
+ run.must("config", "receive.denyNonFastForwards", "true", cwd=repo)
116
+ # `clone --bare` inherits an `origin` pointing at the mirror we are about
117
+ # to delete — a remote that names a directory that no longer exists is a
118
+ # lie a later `git fetch` would trip over. The outbound remote is `forge`
119
+ # (`gitwork/onward.py`), added by the owner; nothing here needs an origin.
120
+ run.git("remote", "remove", "origin", cwd=repo)
121
+ rmtree(legacy, ignore_errors=True)
122
+ return repo
@@ -0,0 +1,168 @@
1
+ """The outbound leg — `repo.git` → the declared forge, after a push lands.
2
+
3
+ §16 ("The host becomes the remote") inverted the forge relationship: the host
4
+ holds the history and GitHub is a full COPY of it. So a push that lands here
5
+ is pushed onward, and `mirror.py`'s pull direction retires — one direction on
6
+ each leg (worktree → host, host → forge), which is what keeps §11's
7
+ replication ban intact through the reversal.
8
+
9
+ **Best effort, and never a gate — in outcome AND in time.** The client's push
10
+ has already landed; nothing here may fail it, revert it, or make it wait. The
11
+ budget is `remote.py::PUSH_TIMEOUT`, reused rather than re-decided, because it
12
+ is the same question with the same answer ("a push is never a gate, so it may
13
+ never cost more than a moment"), and the work runs on a BACKGROUND thread
14
+ started after the response is on the wire: inline, a forge that hangs would
15
+ hold the receive door's thread and delay the very `git push` that already
16
+ succeeded — the client would experience the mirror as a ten-second gate, which
17
+ is the one thing it must never be.
18
+
19
+ **Two pushes racing is normal and needs no lock.** The refspec is the whole
20
+ `refs/heads/*`, so a later push SUBSUMES an earlier one: whichever thread runs
21
+ last leaves the forge holding everything both of them had, and an interleaved
22
+ pair does the same work twice at worst. What does need care is the REPORT,
23
+ which is why `store/mirroring.py` guards its upsert by timestamp — threads may
24
+ finish out of order and an older failure must not overwrite a newer success.
25
+
26
+ **Failure is visible, never swallowed.** Every outcome is recorded as the
27
+ board's `mirror` fact (`store/mirroring.py` argues that channel: the board
28
+ payload, not a log line), including the one an owner is most likely to hit —
29
+ no credential on the host. Nothing here raises into the door.
30
+
31
+ **The credential is the OWNER's, and this module knows nothing about it**
32
+ (§19.2, the escalation). The address is a remote named `forge` inside
33
+ `repo.git`, configured by the owner by hand:
34
+
35
+ git -C <root>/<board>/repo.git remote add forge git@github.com:owner/name.git
36
+
37
+ backed by a WRITE deploy key in the host user's ssh config. It is deliberately
38
+ NOT derived from the declared fact the way `mirror.py::_url` derived its
39
+ anonymous https address: an https push would need a token, and a token is what
40
+ §11 bans. A forge declared with no remote configured is the "no key" case and
41
+ reads as a failure naming that exact command — an owner who declines the
42
+ escalation is told so on every board read, and the read side stays whole.
43
+
44
+ **Fast-forward only, never a force, never a prune.** `--mirror` is the refspec
45
+ that would have made this one line, and it DELETES on the far side; a force
46
+ would rewrite there. The host demands neither of itself (`bare.py` writes
47
+ `receive.denyDeletes` into its own config) and does not do to the forge what it
48
+ refuses for itself. A branch pruned on GitHub simply comes back on the next
49
+ push, which is the whole point of the chapter.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from typing import Any
55
+ from pathlib import Path
56
+ from threading import Thread
57
+
58
+ from . import run, bare, remote
59
+ from .. import _clock
60
+ from ..core import forge as forges
61
+ from ..store import mirroring
62
+ from ..verbs import project
63
+ from .._errors import TaskopsError
64
+ from ..store.stores import Stores
65
+
66
+ __all__ = ["REMOTE", "REFSPEC", "configured", "push", "onward", "after_receive"]
67
+
68
+ REMOTE = "forge"
69
+ """The remote name inside `repo.git`. `origin` is deliberately not reused: on
70
+ the host, `repo.git` has no origin — it was created empty by a client's push
71
+ (`bare.py`) — and a name that says what the far side IS cannot be confused
72
+ with the worktree's own remote by an owner reading `git remote -v`."""
73
+
74
+ REFSPEC = "refs/heads/*:refs/heads/*"
75
+ """Both sides spelled out, for `remote.py::push`'s measured reason (a
76
+ one-sided refspec is resolved through `push.default`), and the whole namespace
77
+ because the host is the source: a card branch that landed here belongs on the
78
+ forge whether or not this particular push mentioned it. Heads only — tags are
79
+ not what this chapter is about, and a tag is a publication."""
80
+
81
+ NO_REMOTE = (
82
+ "nothing was pushed onward: board {board!r} declares the forge {forge} and "
83
+ "this host has no remote named {remote!r} in its repo.git. That credential is "
84
+ "the owner's explicit act (ARCHITECTURE §19.2) — mint a WRITE deploy key for "
85
+ "the repo, install it for the host user, then run: git -C {repo} remote add "
86
+ "{remote} git@{host}:{slug}.git"
87
+ )
88
+ """The failure an owner is most likely to meet, and the only one whose words are
89
+ ours rather than git's: git cannot say "you have not decided yet". It leads with
90
+ the consequence and ends with the exact command, because it is read on a board
91
+ payload where the tail is what a length cap would cut."""
92
+
93
+
94
+ def configured(repo: Path) -> str:
95
+ """The owner's outbound address, or "" — the one question, asked once."""
96
+ result = run.git("remote", "get-url", REMOTE, cwd=repo)
97
+ return result.out if result.ok else ""
98
+
99
+
100
+ def push(repo: Path) -> tuple[bool, str]:
101
+ """`(ok, detail)` — never raises, whatever the network or git does.
102
+
103
+ `run.git` RAISES on a timeout (right for a worker, wrong for a leg nobody
104
+ is waiting on), so the catch is part of the contract exactly as it is in
105
+ `mirror.py`. `detail` is git's own words, because a mirror failure is read
106
+ by the owner who has to fix it: "Permission denied (publickey)" IS the
107
+ instruction, and a sentence of ours in its place would be a guess.
108
+ """
109
+ try:
110
+ result = run.git("push", REMOTE, REFSPEC, cwd=repo, timeout=remote.PUSH_TIMEOUT)
111
+ except TaskopsError as err: # timeout, or no git at all
112
+ return False, str(err)
113
+ if result.ok:
114
+ return True, ""
115
+ said = (result.err or result.out).strip()
116
+ return False, said or f"git push exited {result.code} and said nothing"
117
+
118
+
119
+ def onward(board_dir: Path, stores: Stores, board: str = "") -> dict[str, Any] | None:
120
+ """Push onward and record what happened. None means nothing was attempted.
121
+
122
+ A board with NO declared forge returns None before touching git: no
123
+ mirroring, no attempt, no error — §16's "not a fault", and the state every
124
+ board is born in. No `repo.git` is the same answer: there is nothing to
125
+ copy yet.
126
+ """
127
+ fact = project.forge(stores)
128
+ if fact is None:
129
+ return None
130
+ repo = bare.at(board_dir)
131
+ if repo is None:
132
+ return None
133
+ where = configured(repo)
134
+ if not where:
135
+ ok, detail = False, NO_REMOTE.format(
136
+ board=board or board_dir.name,
137
+ forge=forges.label(fact),
138
+ remote=REMOTE,
139
+ repo=repo,
140
+ host=fact["host"],
141
+ slug=fact["repo"],
142
+ )
143
+ else:
144
+ ok, detail = push(repo)
145
+ at = _clock.now()
146
+ try:
147
+ mirroring.record(stores.live, forges.label(fact), ok=ok, detail=detail, at=at)
148
+ except TaskopsError: # a live store this thread cannot write is not the push's problem
149
+ return None
150
+ return {"forge": forges.label(fact), "ok": ok, "at": at, "detail": detail}
151
+
152
+
153
+ def after_receive(board_dir: Path, stores: Stores, board: str = "") -> Thread | None:
154
+ """Start the outbound leg for a push that just landed, or return None.
155
+
156
+ The thread is a daemon: a host shutting down must not wait on a forge, and
157
+ the next push repeats the whole refspec anyway, so an interrupted mirror
158
+ costs nothing but a report that stays honest about the last one that ran.
159
+ The forge fact is asked HERE too, so a board without one starts no thread
160
+ at all — "no attempt" is a promise about the process table as well.
161
+ """
162
+ if project.forge(stores) is None:
163
+ return None
164
+ thread = Thread(
165
+ target=onward, args=(board_dir, stores, board), name="taskops-mirror", daemon=True
166
+ )
167
+ thread.start()
168
+ return thread