taskops-cli 0.3.1__py3-none-any.whl → 0.3.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.3.1" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.3.2" # single source of truth; pyproject reads it dynamically
taskops/cli/serving.py CHANGED
@@ -110,7 +110,7 @@ def ui(here: Path) -> int:
110
110
  port = httpd.server_address[1]
111
111
  (root / DIR / "ui.json").write_text(json.dumps({"port": port, "token": token}) + "\n")
112
112
  window.retire_when_idle(httpd.mounts, httpd)
113
- _open(f"http://127.0.0.1:{port}/board/ui/?token={token}")
113
+ _open(f"http://127.0.0.1:{port}/?token={token}")
114
114
  try:
115
115
  httpd.serve_forever()
116
116
  except KeyboardInterrupt:
taskops/cli/window.py CHANGED
@@ -104,7 +104,7 @@ def holder(folder: Path, checkout: Path) -> str:
104
104
  f"ctrl-c in its terminal, or wait ~{int(IDLE_SECONDS / 60)} minutes: "
105
105
  "a window with no open tab retires itself"
106
106
  )
107
- return f"http://127.0.0.1:{port}/board/ui/?token={token}"
107
+ return f"http://127.0.0.1:{port}/?token={token}"
108
108
  raise TaskopsError(
109
109
  f"something holds {folder / LOCK} but no window answers on port {port or '?'} — "
110
110
  "it may be starting (run this again) or wedged: find it with "
taskops/gitwork/bind.py CHANGED
@@ -144,7 +144,16 @@ def drain(board: Caller, repo: Path) -> int:
144
144
 
145
145
 
146
146
  def push_card(repo: Path, branch: str) -> bool:
147
- """Publish the card's branch so a PR (or the orchestrator) can see it."""
147
+ """Publish the card's branch so a PR (or the orchestrator) can see it.
148
+
149
+ BOTH sides of the refspec, for the reason `remote.push` carries in full: a
150
+ one-sided `origin <branch>` is resolved through `push.default`, and under
151
+ `upstream` that is the tracked branch and not this one. Here the tracking is
152
+ also what `--set-upstream` is about to write, so the wrong destination and
153
+ the record of it would agree with each other.
154
+ """
148
155
  if not trailer.card_of(branch):
149
156
  return False
150
- return run.git("push", "--set-upstream", "origin", branch, cwd=repo).ok
157
+ return run.git(
158
+ "push", "--set-upstream", "origin", f"{branch}:refs/heads/{branch}", cwd=repo
159
+ ).ok
taskops/gitwork/remote.py CHANGED
@@ -142,6 +142,19 @@ def push(repo: Path, *branches: str, cwd: Path | None = None) -> None:
142
142
  return
143
143
  for name in names:
144
144
  try:
145
- run.git("push", "origin", name, cwd=where, timeout=PUSH_TIMEOUT)
145
+ # BOTH sides of the refspec, always. `git push origin <name>` looks
146
+ # explicit and is not: with no colon, git resolves the destination
147
+ # through `push.default`, and under `upstream` — a setting a human
148
+ # may have in ~/.gitconfig for their own reasons — that destination
149
+ # is the branch's UPSTREAM, not its name. Measured here on
150
+ # 2026-08-10: milestone branches cut in v1 still tracked
151
+ # `origin/main`, so this best-effort visibility push was writing
152
+ # integration branches onto MAIN, in a function that swallows every
153
+ # error, in a repo whose whole point is that only the human lands
154
+ # anything on the trunk. `<name>:refs/heads/<name>` cannot be
155
+ # reinterpreted by any config.
156
+ run.git(
157
+ "push", "origin", f"{name}:refs/heads/{name}", cwd=where, timeout=PUSH_TIMEOUT
158
+ )
146
159
  except TaskopsError: # timeout, or no git at all — never the caller's problem
147
160
  return
taskops/http/gitdoor.py CHANGED
@@ -94,6 +94,7 @@ ABSENT = (
94
94
 
95
95
  HTML = "text/html"
96
96
  TEXT = "text/plain"
97
+ MARKDOWN = "text/markdown"
97
98
 
98
99
 
99
100
  def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
@@ -135,9 +136,8 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
135
136
  reading a report is the UI reading a patch, and a second vocabulary would be
136
137
  a second renderer. `content_type` is a FIELD, never this response's header —
137
138
  every door answers `application/json`, so no path can make THIS origin, the
138
- one the token lives in, serve HTML; the reader sandboxes it. `text/html`
139
- only for a literal `.html`, because a type this door does not know must
140
- degrade to something no renderer will execute."""
139
+ one the token lives in, serve HTML; the reader sandboxes it. The three types
140
+ `_kind` answers are argued there."""
141
141
  path = reports.under(wanted)
142
142
  if not path:
143
143
  raise BadRequest(NOT_A_REPORT.format(path=wanted or "a missing ?path="))
@@ -153,7 +153,7 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
153
153
  return {
154
154
  "path": path,
155
155
  "rev": sha,
156
- "content_type": HTML if path.endswith(".html") else TEXT,
156
+ "content_type": _kind(path),
157
157
  "text": text,
158
158
  "truncated": cut,
159
159
  "cap": patch.CAP,
@@ -186,3 +186,13 @@ def _param(query: str, key: str) -> str:
186
186
  if name == key:
187
187
  return unquote(value.replace("+", " "))
188
188
  return ""
189
+
190
+
191
+ def _kind(path: str) -> str:
192
+ """The three answers, and the default keeps itself safe: an unrecognised
193
+ type falls to `text/plain`, which every reader draws as characters. Decided
194
+ HERE and never in the browser — `ReportFrame` reads what the door SAYS a
195
+ file is, never the extension it can see, so the two cannot drift apart."""
196
+ if path.endswith(".html"):
197
+ return HTML
198
+ return MARKDOWN if path.endswith(".md") else TEXT
taskops/http/handler.py CHANGED
@@ -70,6 +70,8 @@ class Handler(BaseHTTPRequestHandler):
70
70
  self._git(board, tail[4:])
71
71
  elif tail.startswith("ui"):
72
72
  self._static(tail[2:])
73
+ elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
74
+ self._send(200, *page) # a WINDOW serves its page at the ROOT
73
75
  else:
74
76
  self._fail(404, BadRequest(f"nothing at {self.path}"))
75
77
 
taskops/http/static.py CHANGED
@@ -84,6 +84,36 @@ def payload(root: Path, rest: str) -> tuple[bytes, str] | None:
84
84
  return (path.read_bytes(), content_type(path)) if path else None
85
85
 
86
86
 
87
+ def at_root(root: Path | None, path: str) -> tuple[bytes, str] | None:
88
+ """The bundle served at the ROOT of a window — `/`, `/app.js`, `/style.css`.
89
+
90
+ `taskops ui` used to hand out `/board/ui/?token=…`, and that path is an
91
+ implementation detail leaking into the one URL a human types: `board` is
92
+ just the name a window mounts its single board under, and `/ui/` was the
93
+ door on a HOST that no longer serves a page at all. A window is a window;
94
+ its address is its port.
95
+
96
+ Deliberately NOT `resolve`: that one falls back to the index for any
97
+ unknown tail, which is right for a door mounted under `/<board>/ui/` and
98
+ wrong at the root, where it would swallow every mistyped API path and
99
+ answer a mistake with a page. Only real files, plus `/` for the index — so
100
+ `/nope` is still the honest 404 the API gives.
101
+
102
+ `root is None` IS the host switch and the only one there is: `Mounts` sets
103
+ `ui` from the same `repo is not None` that /git and /healthz's identity
104
+ read, so a board host cannot start serving a dashboard by any route,
105
+ including this one — there is no second condition to keep in step.
106
+ """
107
+ if root is None or not root.is_dir():
108
+ return None
109
+ rest = path.partition("?")[0].strip("/")
110
+ base = root.resolve()
111
+ target = (root / INDEX) if not rest else (root / rest).resolve()
112
+ if base != target and base not in target.parents:
113
+ return None # `..%2f..` and symlinks have already collapsed
114
+ return (target.read_bytes(), content_type(target)) if target.is_file() else None
115
+
116
+
87
117
  def answer(root: Path | None, rest: str) -> tuple[int, bytes, str]:
88
118
  """The WHOLE /ui door: (status, body, content-type). `root is None` means
89
119
  this process is a board host — see the module docstring for why that is a
taskops/http/upstream.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """The remote board a LOCAL window is a window onto — the one thing that differs.
2
2
 
3
3
  `taskops ui` always serves the dashboard from the checkout it stands in. The
4
- routes are the same in both modes (`/board/ui/`, `/board/rpc`, `/board/git/…`),
4
+ routes are the same in both modes (`/` for the page, `/board/rpc`, `/board/git/…`),
5
5
  so the committed bundle never learns which kind of board it is talking to. The
6
6
  whole difference is who answers `/rpc`:
7
7