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 +1 -1
- taskops/cli/gitremote.py +198 -0
- taskops/cli/main.py +10 -1
- taskops/cli/operate.py +2 -2
- taskops/cli/parser.py +18 -3
- taskops/cli/remote.py +11 -1
- taskops/core/forge.py +10 -0
- taskops/gitwork/bare.py +122 -0
- taskops/gitwork/onward.py +168 -0
- taskops/gitwork/run.py +83 -1
- taskops/http/auth.py +41 -1
- taskops/http/gitbody.py +69 -0
- taskops/http/gitdoor.py +30 -28
- taskops/http/gitpack.py +176 -0
- taskops/http/handler.py +22 -38
- taskops/http/mounts.py +3 -3
- taskops/http/page.py +42 -0
- taskops/http/repos.py +75 -64
- taskops/http/routes.py +32 -0
- taskops/http/stale.py +61 -28
- taskops/http/static.py +43 -19
- taskops/mcp/boardview.py +20 -0
- taskops/store/live.py +5 -0
- taskops/store/mirroring.py +96 -0
- taskops/ui/app.js +9 -9
- taskops/ui/index.html +4 -2
- taskops/verbs/pulse.py +5 -0
- {taskops_cli-0.5.0.dist-info → taskops_cli-0.5.2.dist-info}/METADATA +99 -8
- {taskops_cli-0.5.0.dist-info → taskops_cli-0.5.2.dist-info}/RECORD +31 -24
- taskops/gitwork/mirror.py +0 -132
- {taskops_cli-0.5.0.dist-info → taskops_cli-0.5.2.dist-info}/WHEEL +0 -0
- {taskops_cli-0.5.0.dist-info → taskops_cli-0.5.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
taskops/cli/gitremote.py
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
taskops/gitwork/bare.py
ADDED
|
@@ -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
|