uberepo 0.0.0

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.
@@ -0,0 +1,487 @@
1
+ # uberepo reference
2
+
3
+ Full command + flag detail for the `using-uberepo` skill. Load this when you
4
+ need the exact flags, the conflict/refusal recovery steps, or the sharing flow.
5
+ `uberepo --help` (and `uberepo <command> --help`) is the live source of truth —
6
+ prefer it if this file and the CLI ever disagree.
7
+
8
+ ## Workspace layout
9
+
10
+ <workspace>/
11
+ ├── uberepo.json # manifest: the registered repos, hooks, carry
12
+ ├── source/<repo>/ # canonical clone of each repo — DON'T work here
13
+ └── tasks/<task>/<name>/ # per-task worktree, on branch task/<task> (or task/<task>@<alias>)
14
+
15
+ - `source/<repo>/` is the shared base clone. Never edit, commit, branch, or run
16
+ raw `git` in it.
17
+ - `tasks/<task>/<name>/` is where you work. `<name>` is the **participant**: a bare
18
+ repo (`web`, branch `task/<task>`) or a `repo@alias` token (`web@auth`, branch
19
+ `task/<task>@auth`) when one repo carries several branches in the task. The folder
20
+ is flat one level either way, and all of a repo's participants share its one
21
+ `source/<repo>` clone. Under `tasks/<task>/`, you're already in a worktree — commit there.
22
+ - One task = one branch per participant: `task/<task>` for a plain repo, an extra
23
+ `repo@alias` participant for another branch in the same repo. Switch by switching
24
+ directories, not by `git checkout`.
25
+
26
+ ## Discover state (machine-readable)
27
+
28
+ Re-read state before you report on it; don't scrape human-formatted text.
29
+
30
+ | Command | Returns |
31
+ | --- | --- |
32
+ | `uberepo sources --json` | Registered repos + whether each is cloned. |
33
+ | `uberepo status --json` | Open tasks; each worktree's branch + clean/dirty. |
34
+ | `uberepo diff <task> --json` | The task's footprint per repo: commits ahead of the origin default + diffstat. |
35
+ | `uberepo context <task> --json` | Everything to resume the task: the note + diff's per-repo footprint + PR state per branch. |
36
+
37
+ Drop `--json` for a human-readable table. If `uberepo` isn't on `PATH`, it's
38
+ being run from source — check the workspace `README`.
39
+
40
+ `--json` is a global flag on **every** command, not just the ones above — pass it
41
+ to any command to get a single stable JSON object describing its outcome (and no
42
+ human lines). See [JSON output](#json-output) for the full per-command schema.
43
+
44
+ ### `uberepo diff <task>` — the task's footprint
45
+
46
+ Read-only report, per repo in the task's scope: the `task/<task>` branch, the
47
+ commits it carries beyond the merge-base with the comparison base (full sha +
48
+ subject, newest first), and the diffstat over that same range. The base is
49
+ resolved exactly like `sync`'s default rebase target — origin's default branch
50
+ (`origin/HEAD`, e.g. `origin/main`) — but **nothing is fetched**: the comparison
51
+ is against the last-fetched upstream state. A repo with no worktree or a
52
+ vanished task branch is reported as `skipped` with a reason, never an error,
53
+ and no hooks fire (it's not a lifecycle op). A `dirty` flag marks a worktree
54
+ with uncommitted changes — **those changes are NOT in the numbers**; commit
55
+ them to see them counted. A **stacked** participant ([`--stack`](#uberepo-open-task--start-a-task))
56
+ is compared against its **parent's branch**, not the repo default, so its
57
+ ahead-count/diffstat are its own commits beyond the sibling it sits on; the
58
+ human output nests it under its parent in a `└─` tree, and `--json` carries the
59
+ edge as `parent`/`base` per entry.
60
+
61
+ ### `uberepo context <task>` — the resume-a-task handoff
62
+
63
+ One read-only blob of everything a fresh session needs to pick the task up:
64
+ the parsed note (goal / tickets / decisions / blockers + freshness), `diff`'s
65
+ per-repo footprint (branch, commits ahead, diffstat, dirty), and each branch's
66
+ PR state. Human mode prints a small **markdown document** — built to be piped
67
+ or pasted (Slack handoff, PR cover) — with empty sections omitted; `--json` is
68
+ the same data structured. PR state comes from `gh pr view`, run in each repo's
69
+ worktree: no `gh` on PATH → every PR field is silently omitted (no flag — the
70
+ degradation is automatic); a branch without a PR shows `no PR` (JSON: `pr`
71
+ absent); any `gh` error reads as no-PR, never an abort. Like `diff`: nothing
72
+ is fetched, no hooks fire, and a repo that can't be read is `skipped` with a
73
+ reason — and a **stacked** child is measured against its parent's branch, nested
74
+ under it in the markdown (and carrying `parent`/`base` in `--json`), exactly as
75
+ `diff` does.
76
+
77
+ ## Task lifecycle
78
+
79
+ ### `uberepo open <task>` — start a task
80
+
81
+ Creates the `tasks/<task>/<name>/` worktree and the `task/<task>` branch in every
82
+ cloned repo, off each clone's current HEAD.
83
+
84
+ - `--from <ref>` — base the branches off `<ref>` instead of current HEAD.
85
+ - `--branch <repo>=<name>` (repeatable; a bare `--branch <name>` applies to every
86
+ repo in scope) names the branch instead of `task/<task>`. If that branch already
87
+ exists (local **or** `origin/<name>`) uberepo **adopts** it — the worktree attaches
88
+ to the existing branch (an origin-only branch becomes a local tracking branch)
89
+ rather than creating one; otherwise it's created normally. Adopted branches are
90
+ recorded in the note's [`branches:`](#schema) map, and `close`/`prune` **keep**
91
+ them (they remove the worktree but never delete an adopted branch — it's a real
92
+ branch, usually with an open PR). uberepo also reads an adopted branch's **base**
93
+ from its PR (`gh pr view`), so `sync`/`diff`/`ship` use that base (e.g. for a
94
+ stacked branch) instead of the repo default. Unset → `task/<task>`. Mixing the two
95
+ forms, or naming a repo outside the task's scope, is an error.
96
+ - `--goal "<text>"` — set the task note's `goal` (creates/updates `ubertask.yml`).
97
+ - `--repos <name>...` — **ADDITIVE scope; it only ever grows, never narrows.**
98
+ On a brand-new task it sets the initial scope (only the named repos get
99
+ worktrees) and records it as the note's `repos:`. On a re-open the supplied
100
+ names are unioned into the stored scope, never replacing it. An unscoped task
101
+ (`repos: []` = every cloned repo, the maximal set) CANNOT be narrowed: naming
102
+ `--repos` on it leaves it unscoped and simply clones+opens any named repo not
103
+ already present, never stranding the worktrees it already owns. Omit `--repos`
104
+ to leave the scope unchanged. No `--repos` ever = unscoped.
105
+ - **`repo@alias` — several branches in one repo.** A `--repos` entry (and a `repos:`
106
+ note entry) is `repo` or `repo@alias`. The same repo may appear several times with
107
+ different aliases; each is its own **participant** — its own worktree
108
+ (`tasks/<task>/repo@alias/`, flat) and branch (`task/<task>@alias`) — and they all
109
+ share the one `source/<repo>` clone. Bare `repo` is unchanged (`task/<task>`). A
110
+ repo or alias name may not contain `@`, `:`, or glob chars (`[ ] * ?`), be a Windows
111
+ reserved name (`con`/`prn`/`aux`/`nul`/`com1`–`9`/`lpt1`–`9`), or end in a dot or
112
+ space; participants must be unique case-insensitively.
113
+ - **`--stack <child>=<parent>` — stack one branch on a sibling.** Repeatable. Records
114
+ the **child** participant's [`branches:`](#schema) `base` as the **parent**
115
+ participant token (`--stack web@logos=web@strings` → `web@logos`'s base is
116
+ `web@strings`) — a *stack edge*, a sibling reference rather than a remote ref. From
117
+ then on `ship` opens the child's PR against the parent's branch and `sync` rebases
118
+ the child onto the freshly-moved parent (see those commands). An explicit `--stack`
119
+ **overrides** a PR-discovered base for that participant. Both ends must be in the
120
+ task's scope and the **same repo** (a branch can only stack on a sibling of its own
121
+ repo), and the edges must stay **acyclic** — a parent outside scope, a cross-repo
122
+ pairing, or a cycle is rejected up front with the offending edge named (fix it with
123
+ `--repos`/the right token, or drop the `--stack`). A participant with no `--stack`
124
+ is an ordinary root.
125
+ - **Named repos clone on demand.** A repo explicitly asked for (named by
126
+ `--repos` now, or stored in the note's `repos:` on a re-open) that is
127
+ registered but not yet cloned is cloned into `source/<repo>` first — its
128
+ `pre-clone`/`post-clone` hooks fire exactly as under `uberepo clone` — then
129
+ opened like any other repo. ONLY explicitly named repos do this; an unscoped
130
+ open with no `--repos` never clones anything. A failed on-demand clone is
131
+ reported for that repo, the run continues with the rest, and the command exits
132
+ non-zero at the end (re-running retries it). A `--repos` name that isn't
133
+ registered at all is an error.
134
+ - Idempotent: re-running skips repos already opened and picks up repos cloned
135
+ since the first run.
136
+ - If `uberepo.json` declares [carry](#carry--local-files-into-worktrees)
137
+ patterns, matching untracked local files (`.env` and friends) are copied
138
+ from `source/<repo>` into each fresh worktree, before its `post-open` hook.
139
+
140
+ ### Work — edit, commit, push (per repo)
141
+
142
+ Edit inside `tasks/<task>/<name>/`. **uberepo does NOT commit or push for you** —
143
+ `git add`/`commit`/`push` inside each repo's worktree. Follow that repo's own
144
+ `AGENTS.md`/`README` for its build, test, and commit conventions.
145
+
146
+ ### `uberepo exec <task> -- <cmd>...` — run a command in every worktree
147
+
148
+ Runs one command in each of the task's worktrees, in turn — `npm test`, a lint
149
+ script, a codemod — so one invocation drives the whole task's repos instead of
150
+ `cd`-ing through each. uberepo runs the command; what it does is yours to own.
151
+
152
+ - **`--` is required**, and splits uberepo's own flags from the command —
153
+ everything after it is the command, run verbatim. `uberepo exec <task> --json
154
+ -- npm test --watch`: `--json` is uberepo's, `npm test --watch` is the command.
155
+ - **No shell.** The command runs as a bare program + arguments (the way uberepo
156
+ runs `git`), not through `sh` — so `;`, `|`, `&&`, and globs are literal args,
157
+ not operators. Need a shell? Run `... -- sh -c "<line>"`.
158
+ - `--repos <name>...` — run only in this subset. A **transient filter** for this
159
+ run (it does NOT change the note's `repos:` scope); a name not in the task is an
160
+ error. Like `ship`, an in-scope repo with no worktree simply doesn't take part.
161
+ - `--bail` — stop at the first repo whose command exits non-zero. Default: run
162
+ every repo and report each.
163
+ - Each command inherits the same `UBEREPO_*` env a hook gets (`UBEREPO_TASK`,
164
+ `UBEREPO_REPO`, `UBEREPO_REPO_PATH`, `UBEREPO_REPO_URL`, `UBEREPO_BRANCH`,
165
+ `UBEREPO_WORKSPACE`) — minus the hook-only `UBEREPO_EVENT`/`UBEREPO_PR_URL`.
166
+ - Runs **sequentially** in scope order. A non-zero exit in any repo flips exec's
167
+ own exit code (so a wrapper/CI sees it) but the run continues unless `--bail`.
168
+ Human mode streams each repo's output live under a `▸ <repo> $ <cmd>` header;
169
+ `--json` captures per-repo `stdout`/`stderr`/`exitCode` and prints no live output.
170
+ Not a lifecycle op: no hooks, no carry, no fetch.
171
+
172
+ ### `uberepo sync <task>` — rebase onto fresh upstreams
173
+
174
+ Fetches and rebases each worktree onto its repo's fresh default branch.
175
+
176
+ - `--from <ref>` — rebase onto `<ref>` instead of the default branch.
177
+ - **Refuses to start if any worktree is dirty** — commit or stash first.
178
+ - **Stacks rebase bottom-up.** A [stacked](#uberepo-open-task--start-a-task)
179
+ participant doesn't rebase onto the repo default — it rebases onto its
180
+ **parent's** freshly-moved branch, and sync walks the per-repo stack forest
181
+ **topologically** (every parent before its children) so a rebase ripples up
182
+ the stack without flattening it. A repo with no commits ahead (already
183
+ restacked / up-to-date) is reported `current`. If an ancestor's rebase
184
+ conflicts (or is otherwise not reached), its descendants are pruned with
185
+ `"parent not synced"` — fix the parent, then re-run to carry the stack the
186
+ rest of the way.
187
+ - **A conflict isolates — it doesn't halt the run**: the conflicting repo is
188
+ left mid-rebase and its stacked descendants are pruned (`"parent not synced"`),
189
+ but every other repo and independent root in the same run still rebases.
190
+ Resolve it in that worktree (`git add` the resolved files, `git rebase
191
+ --continue`, or `git rebase --abort` to back out), then re-run `uberepo sync
192
+ <task>` to finish the conflicted branch and carry its stack the rest of the way.
193
+ - `--check` — a conflict **forecast**: fetch (the one ref update), then predict
194
+ each repo's rebase with `git merge-tree` — no rebase, no hooks, no carry, no
195
+ worktree mutation. Per repo: `current` (the target is already contained —
196
+ sync would no-op), `clean` (rebase likely clean), `conflicts` (+ the likely
197
+ conflicted files), `dirty` (uncommitted changes — the real sync would refuse;
198
+ `--check` never refuses, it flags the repo and keeps forecasting the rest),
199
+ `skipped` (+ reason). It's a forecast, not a promise: merge-tree merges the
200
+ two tips in one step while a real rebase replays commits one-by-one, so a
201
+ multi-commit branch can differ. Exits 0 even when conflicts are forecast.
202
+ Needs git >= 2.38.
203
+
204
+ ### `uberepo ship <task>` — push + open a draft PR per branch
205
+
206
+ Pushes each participant's branch and opens a **draft** pull request for it. One PR
207
+ per branch — a repo with several participants gets several PRs, grouped under it and
208
+ sharing its base discovery and PR-template lookup; nothing is merged.
209
+
210
+ - **Requires the GitHub CLI** (`gh`) unless `--no-pr`: install https://cli.github.com
211
+ then `gh auth login`. ship shells out to `gh` (it does not call the API), running
212
+ it in each repo's worktree so `gh` infers the repo from its origin. Without `gh`
213
+ (and not `--no-pr`) it errors and does nothing.
214
+ - **Draft is always on.** PRs open as drafts; mark them ready in the GitHub UI.
215
+ - `--repos <name>...` — ship only this subset of the task's repos. A **transient
216
+ filter** for this run: it does NOT change the note's `repos:` scope. A name not in
217
+ the task is an error.
218
+ - `--title <text>` — PR title for every PR this run. Otherwise the title resolves:
219
+ `--title` → the note's `goal` (first line) → the task name (never titleless).
220
+ - `--body <text>` / `--body-file <path>` — PR body for every PR this run (mutually
221
+ exclusive). Otherwise the body is the repo's `.github` PR template, else empty.
222
+ The template is looked up case-insensitively as `pull_request_template.md` in
223
+ `.github/`, the repo root, or `docs/` (a multi-template `PULL_REQUEST_TEMPLATE/`
224
+ directory is ignored). `gh` does NOT auto-apply templates, so ship reads the file
225
+ and passes it as the body. **Nothing else is appended to the body.**
226
+ - `--base <ref>` — base branch for the PRs (default: each repo's remote default,
227
+ e.g. `main`). A [stacked](#uberepo-open-task--start-a-task) child ignores this:
228
+ its PR always targets its **parent's branch** (e.g. `task/<task>@strings`), and
229
+ ship pushes the **parents first** so the base exists. A child whose parent isn't
230
+ on the remote yet is **skipped** with `parent <token> not on remote — ship it
231
+ first` — ship the parent, then re-run.
232
+ - `--no-pr` — push only; skip every `gh` call. The one mode that needs no `gh`.
233
+ - `--force` — push with `--force-with-lease` (needed after a `sync`/rebase diverges
234
+ the already-pushed branch). The default push is plain and refuses a diverged push
235
+ with a "did you sync? re-run with --force" hint.
236
+
237
+ Per repo: a branch with no commits ahead of base is **skipped** (an empty PR is
238
+ rejected); a dirty worktree is **skipped** (`uncommitted changes`); otherwise the
239
+ branch is pushed and, unless `--no-pr`, a draft PR is **created**. **Idempotent
240
+ re-run:** if a PR already exists for the branch, ship just pushes (the PR
241
+ auto-reflects the new commits) and **leaves the existing PR's title and body
242
+ untouched** — it never clobbers human edits. A push/`gh` failure in one repo is
243
+ reported and the run continues to the rest, then exits non-zero.
244
+
245
+ ### `uberepo close <task>` — finish a task
246
+
247
+ Removes every participant's worktree and deletes its branch in every repo — a repo
248
+ with several participants loses all of them, but the shared `source/<repo>` clone
249
+ stays and an adopted branch is never deleted.
250
+
251
+ - **Refuses any repo with uncommitted OR unmerged work.** Push your branches
252
+ first.
253
+ - `--force` — skip the refusal. Only when you are certain the work is saved or
254
+ pushed; this can drop commits.
255
+
256
+ ## The task note — `ubertask.yml`
257
+
258
+ `open` seeds `tasks/<task>/ubertask.yml`: a per-task handoff note carrying the
259
+ durable context git can't regenerate — the goal, related links, deliberate
260
+ decisions, and known blockers. git holds the live state (what changed, what's
261
+ done, what's left); the note holds the *why*. It's gitignored and dies with the
262
+ task on `close`.
263
+
264
+ ### Schema
265
+
266
+ # ubertask.yml — durable task note. The "why"; git holds the "what".
267
+ goal: |
268
+ Kill the SSO redirect loop — users bounce /login ↔ /callback
269
+ repos:
270
+ - api
271
+ - web
272
+ branches:
273
+ api:
274
+ name: fix/sso-loop
275
+ adopted: true
276
+ base: develop
277
+ tickets:
278
+ - https://acme.atlassian.net/browse/PROJ-1234
279
+ decisions:
280
+ - note: |
281
+ keep /v1 alive — mobile still rides it
282
+ repo: api
283
+ blockers:
284
+ - note: |
285
+ dev server needs api on :8080 first or /callback 502s
286
+ repo: web
287
+
288
+ - `goal` — one-line `|` block: what done looks like and why. Always set it.
289
+ - `repos` — the task's declared **scope**: the participants it owns, each a bare repo
290
+ or a `repo@alias` token (`source/<repo>` is the shared clone behind it).
291
+ `open --repos` writes it and only ever GROWS it (additive — it never narrows an
292
+ existing scope); `sync`/`close`/`prune` act only on these repos and warn about a
293
+ worktree outside the scope. Empty (`repos: []`) = unscoped (every cloned repo)
294
+ and stays empty — an unscoped task can't be narrowed by `--repos`. This is the
295
+ task's scope — distinct from a decision/blocker item's `repo:`.
296
+ - `branches` — branch overrides, keyed by the **participant token** (`web` or
297
+ `web@auth`): each entry is `{ name, adopted, base? }`. A participant on its default
298
+ branch — `task/<task>` (bare) or `task/<task>@<alias>` (aliased) — records nothing
299
+ and resolves by default; an entry appears only when adoption or `--branch` deviates
300
+ from it. `adopted: true` marks a pre-existing branch uberepo attached to rather
301
+ than created — `close`/`prune` keep it; `base` is its rebase/PR target, auto-filled
302
+ from the branch's PR for adopted branches, else the repo default. `base` may also
303
+ name **another participant token** in the task (`web@logos`'s `base: web@strings`) —
304
+ a **stack edge** written by [`open --stack`](#uberepo-open-task--start-a-task), not a
305
+ remote ref: `ship`/`sync`/`diff`/`context` then target/rebase/compare the participant
306
+ against that sibling's branch instead of a remote default.
307
+ - `tickets` — list of URLs (issue, PR, doc, thread).
308
+ - `decisions` / `blockers` — lists of `{ note: |, repo? }`. `note` is a `|` literal
309
+ block (free text — colons, `#`, slashes need no quoting). `repo:` is optional —
310
+ a `source/<repo>` when the item is about one repo; omit it for cross-cutting items.
311
+
312
+ ### Keep it honest
313
+
314
+ - **Resuming:** read the note for standing context, then reconcile against
315
+ `git status`/`git diff`. It's a hint, not truth — reality wins; fix the note
316
+ when they disagree.
317
+ - **Working:** set `goal` on `open`; append a `decision`/`blocker` the moment it
318
+ lands, tagging `repo:` when it's repo-specific.
319
+ - **Don't store what git knows** — no progress, next-steps, changed files, or
320
+ dates. `uberepo status` surfaces the note's freshness from its mtime.
321
+
322
+ ## Lifecycle hooks
323
+
324
+ Run a shell command in each repo around every uberepo git op — install deps
325
+ after a clone, gate a push on the tests, clean up after a close. Declared in
326
+ `uberepo.json` as a `hooks` map of event → command **string** (a command line
327
+ run through the shell, so any interpreter works: `npm ci`, `bash x.sh`,
328
+ `python3 y.py`, an inline one-liner).
329
+
330
+ {
331
+ "repositories": ["git@github.com:acme/api.git"],
332
+ "hooks": {
333
+ "post-open": "npm install",
334
+ "pre-ship": "npm test"
335
+ }
336
+ }
337
+
338
+ Every lifecycle command has a **pre** and a **post** event (ten total). Each
339
+ fires **per repo**, and **only for repos that did the work** — never a skipped
340
+ one (already cloned, already open, dirty, nothing to ship).
341
+
342
+ - **pre-\* GATES the op:** a non-zero exit skips that repo (the op never runs),
343
+ the run continues with the other repos, and the command exits non-zero. Fix
344
+ the cause and re-run — the skipped repos are picked up. A failed `pre-close`
345
+ leaves the worktree and branch standing.
346
+ - **post-\* reports:** fires right after the op succeeds; a non-zero exit is
347
+ logged and flips the exit code, but nothing is rolled back.
348
+
349
+ | Event | Around | cwd |
350
+ | --- | --- | --- |
351
+ | `pre-clone` | a repo's fresh clone | workspace root (`UBEREPO_REPO_PATH` = the would-be `source/<repo>`) |
352
+ | `post-clone` | after the clone lands | `source/<repo>/` |
353
+ | `pre-open` | a new task worktree | `source/<repo>/` (`UBEREPO_REPO_PATH` = the would-be worktree) |
354
+ | `post-open` | after the worktree lands | `tasks/<task>/<name>/` |
355
+ | `pre-sync` | a worktree's rebase | `tasks/<task>/<name>/` |
356
+ | `post-sync` | after a clean rebase | `tasks/<task>/<name>/` |
357
+ | `pre-ship` | a repo's push + PR | `tasks/<task>/<name>/` |
358
+ | `post-ship` | after push (and PR unless `--no-pr`) | `tasks/<task>/<name>/` |
359
+ | `pre-close` | a worktree's teardown | `tasks/<task>/<name>/` |
360
+ | `post-close` | after worktree + branch are gone | `source/<repo>/` (`UBEREPO_REPO_PATH` = the removed worktree) |
361
+
362
+ The clone events fire wherever a clone actually happens — `uberepo clone`, or
363
+ an `open` cloning a scoped repo on demand — always with the same cwd and the
364
+ task-free env below.
365
+
366
+ An unknown event key is a config error (listing the valid events). A manifest
367
+ with no `hooks` key behaves exactly as before — hooks are entirely opt-in.
368
+
369
+ **Environment** — every hook inherits the parent environment plus:
370
+
371
+ | Var | Value |
372
+ | --- | --- |
373
+ | `UBEREPO_EVENT` | the event name (one of the ten above) |
374
+ | `UBEREPO_WORKSPACE` | absolute workspace root |
375
+ | `UBEREPO_REPO` | the participant: the bare repo name (`web`), or the `repo@alias` token (`web@auth`) for an aliased worktree |
376
+ | `UBEREPO_REPO_PATH` | absolute path of the dir the event is about (usually the cwd; see table) |
377
+ | `UBEREPO_REPO_URL` | the repo's registered clone URL |
378
+ | `UBEREPO_TASK` | the task name (empty for the clone events) |
379
+ | `UBEREPO_BRANCH` | the participant's branch — `task/<task>` (bare) or `task/<task>@<alias>` (aliased) by default, or the adopted / `--branch` name when one was set (empty for the clone events) |
380
+ | `UBEREPO_PR_URL` | the PR's URL in `post-ship` once created/found; empty otherwise (incl. `--no-pr`) |
381
+
382
+ - **cwd gotcha:** a hook runs with its cwd set to the dir in the table, not the
383
+ workspace root. Anchor any script path with `$UBEREPO_WORKSPACE`, not a bare
384
+ relative path.
385
+ - **Failure:** a non-zero exit is logged, the run **continues** to the next
386
+ repo, and the command exits non-zero with a summary. pre-* failure = that
387
+ repo's op never ran (re-run picks it up); post-* failure = the op stands.
388
+ - **Kill switch:** pass `--no-hooks` (on `clone`/`open`/`sync`/`ship`/`close`),
389
+ or set the `UBEREPO_NO_HOOKS` env var, to skip every hook for that run.
390
+
391
+ ## Carry — local files into worktrees
392
+
393
+ A fresh worktree has only tracked files, so untracked local config (`.env`,
394
+ override files, certs) stays behind in `source/<repo>`. The top-level `carry`
395
+ field in `uberepo.json` names the untracked files to copy into task worktrees.
396
+ It's an array (global — every repo carries it) **or** an object keyed by repo
397
+ name (per repo), never both; omit it and nothing is carried. A per-repo key
398
+ matching no registered repo is warned about. `repositories` is a plain list of
399
+ URL strings.
400
+
401
+ {
402
+ "repositories": [
403
+ "git@github.com:acme/api.git",
404
+ "git@github.com:acme/web.git"
405
+ ],
406
+ "carry": { "api": [".env*"], "web": ["certs/*.pem"] }
407
+ }
408
+
409
+ - Patterns are relative to the repo root and anchored there; `*`/`?` don't
410
+ cross `/`, `**` does (`**/.env*` = any depth). Dotfiles match normally.
411
+ - Only files git does NOT track (untracked + ignored) are copied; a pattern
412
+ matching a tracked file is warned about and skipped, never copied.
413
+ - **Never overwrites**: a file already in the worktree is kept (your in-task
414
+ edits win), so carry is idempotent.
415
+ - `open` carries into each fresh worktree **before its `post-open` hook**;
416
+ `sync` re-carries (missing files only) **before `post-sync`** — hooks can
417
+ rely on the files being there. `close` warns (warn-only, never blocks) when
418
+ a carried file was modified in the task: those edits die with the worktree,
419
+ so copy anything valuable out first.
420
+ - Carry gitignored files. A carried file that is NOT ignored counts as
421
+ untracked work in the worktree — it will trip `sync`'s dirty refusal and
422
+ `close`'s uncommitted-changes guard like any hand-made file.
423
+
424
+ ## Set up / share a workspace
425
+
426
+ | Command | Effect |
427
+ | --- | --- |
428
+ | `uberepo init [name]` | Create a new workspace (manifest + agent files). |
429
+ | `uberepo add <url>...` | Register one or more repos in one call. |
430
+ | `uberepo remove <url>` | Unregister a repo. |
431
+ | `uberepo clone [--repos <name>...]` | Clone every registered repo into `source/<repo>` (skips ones already cloned); `--repos` clones only the named ones (an unknown name is an error). |
432
+ | `uberepo pull` | Fast-forward every cloned repo in `source/` to its origin (skips dirty or diverged repos). |
433
+
434
+ - `add`/`remove` match repos by host/owner/repo identity, so any URL form works.
435
+ Don't hand-edit `uberepo.json` — use these.
436
+ - **Sharing:** commit `uberepo.json` (the stamped `.gitignore` keeps `source/`
437
+ and `tasks/` out of git). A colleague clones the workspace repo and runs
438
+ `uberepo clone` to rehydrate every registered repo into `source/`.
439
+
440
+ ## Cleanup
441
+
442
+ - `uberepo prune` — preview tasks whose branches are fully merged.
443
+ - `uberepo prune --force` — remove those merged tasks.
444
+
445
+ ## Refusal recovery (quick map)
446
+
447
+ | Refusal | Cause | Fix |
448
+ | --- | --- | --- |
449
+ | `sync` won't start | a worktree is dirty | commit or stash in that worktree, re-run |
450
+ | `sync` stopped mid-run | rebase conflict | resolve in the worktree, `git rebase --continue` (or `--abort`), re-run `sync` |
451
+ | `sync` pruned a stacked child | its parent's restack conflicted (`"parent not synced"`) | resolve the parent's conflict, `git rebase --continue`, then re-run `sync` — the child restacks once the parent lands |
452
+ | `close` refused | uncommitted/unmerged work | commit + push, then re-run; `--force` only if the work is truly saved |
453
+ | `prune` skipped a task | branch not fully merged | merge/push first, or leave it; `--force` removes regardless |
454
+
455
+ ## JSON output
456
+
457
+ Pass `--json` to any command for one JSON object describing its outcome — no
458
+ human lines. Stable, additive contract; parse this instead of scraping text.
459
+ Optional keys (`reason`, `error`, `note`) are omitted when they don't apply.
460
+
461
+ Per-participant `name`/`repo` fields carry the **participant token** — the bare repo
462
+ (`web`) or the `repo@alias` form (`web@auth`) — so a repo's several branches stay
463
+ distinct in the output.
464
+
465
+ | Command | JSON shape |
466
+ | --- | --- |
467
+ | `init` | `{ workspace, created: true, agents: bool }` |
468
+ | `add` | `{ added: string[], skipped: string[] }` — `added` = flat names added; `skipped` = URLs already registered |
469
+ | `remove` | `{ removed: string[], notFound: string[] }` — each the normalized host/owner/repo key |
470
+ | `sources` | `[{ name, url, cloned }]` |
471
+ | `clone` | `{ repos: [{ name, status: "cloned" \| "skipped" \| "failed", reason?, error? }], hooks: [{ event, repo, exit }] }` — fail-fast: at most one `failed` (last entry), then the command exits non-zero; `reason` (skip): `"pre-clone hook failed"`; `hooks` lists every hook that ran (pre and post); with `--repos <name>...` the same shape, just only the named repos |
472
+ | `pull` | `{ repos: [{ name, status: "updated" \| "current" \| "skipped", reason? }] }` — `reason`: `"not cloned"`, `"uncommitted changes"`, `"can't fast-forward"` |
473
+ | `status` | `[{ name, repos: [{ name, branch?, dirty, parent?, base? }], note? }]` — a repo entry gains `parent` (the sibling token its branch stacks on) and `base` (that sibling's branch) only when it's a stacked child; a repo's entries come parent-before-child |
474
+ | `diff` | `{ task, base, repos: [{ name, branch, parent?, base, ahead, dirty, files, insertions, deletions, commits: [{ sha, subject }], status: "ok" \| "skipped", reason? }] }` — top-level `base` is the resolved comparison ref (e.g. `origin/main`; `""` if never resolved); each entry's per-row `base` is the ref IT was compared against (a stacked child's parent branch, else the run base), and `parent` (present only on a stacked child) is the sibling token it sits on (entries are ordered parent-before-child); an `ok` repo carries the numbers (`commits` newest first, full `sha`; `dirty` = uncommitted changes, NOT counted in the numbers); a `skipped` repo carries only `name`, `branch`, the per-row `base`/`parent`, and `reason`: `"no worktree"`, `"branch missing"`, `"cannot resolve origin's default branch"` |
475
+ | `context` | `{ task, base, note?, repos: [{ name, branch, parent?, base, ahead, dirty, files, insertions, deletions, commits: [{ sha, subject }], pr?: { number, url, draft, state }, status: "ok" \| "skipped", reason? }] }` — `diff`'s footprint per repo (same fields, same per-row `base`/`parent` stack edge, same skip reasons, same parent-before-child order) plus `pr` when `gh` knows a PR for the branch (`draft` bool; `state`: gh's `OPEN`/`CLOSED`/`MERGED`); `pr` absent when the branch has no PR or `gh` is missing/failed (automatic degradation, never an error); `note` is the full task note (see below), omitted when the task has none |
476
+ | `open` | `{ task, scope: string[], repos: [{ name, status: "created" \| "skipped", reason? }], clone: [{ name, status: "cloned" \| "skipped" \| "failed", reason?, error? }], hooks: [{ event, repo, exit }], carry: [{ repo, copied, keptExisting, skippedTracked }], note? }` — `reason` (skip): `"pre-open hook failed"`, `"pre-clone hook failed"`, `"clone failed"`, `"not registered"`; `clone` has one entry per scoped repo cloned on demand this run (same entry shape as `clone`'s repos; a `failed` entry means that repo got no worktree, the run continued, and the exit code is non-zero); `hooks` lists every hook that ran (pre and post, the clone events included); `carry` has one entry per fresh worktree in a repo with carry patterns (`copied`/`keptExisting`/`skippedTracked`: string[] of repo-relative paths); `note` is the full task note (see below); absent only when nothing is cloned |
477
+ | `exec` | `{ task, command: string[], repos: [{ name, branch, exitCode?, status: "ok" \| "failed" \| "skipped", stdout?, stderr? }] }` — `command` is the argv after `--`; one entry per worktree it ran in, in sequence: `status` is `"ok"` (exit 0) or `"failed"` (non-zero), each carrying the child's `exitCode` and captured `stdout`/`stderr`. Exits non-zero if any repo failed; `--bail` stops after the first. A `skipped` entry (no `exitCode`) is an in-scope repo with no worktree — like `ship`, those normally don't appear at all |
478
+ | `sync` | `{ task, onto, repos: [{ name, base?, status: "rebased" \| "current" \| "conflict" \| "skipped", reason? }], hooks: [{ event, repo, exit }], carry: [{ repo, copied, keptExisting, skippedTracked }] }` — `status` adds `current` (already restacked / up-to-date — nothing to rebase); a stacked child carries the per-entry `base` it was rebased onto (its parent's branch); `reason`: `"uncommitted changes"`, `"not reached"`, `"parent not synced"` (a stacked descendant pruned because an ancestor wasn't reached), `"cannot resolve origin's default branch"`, `"pre-sync hook failed"`; entries come parent-before-child; `onto` is `""` if it bailed before resolving; `hooks` lists every hook that ran (pre and post); `carry` has one entry per cleanly-rebased repo with carry patterns |
479
+ | `sync --check` | `{ task, onto, check: true, repos: [{ name, status: "clean" \| "conflicts" \| "current" \| "dirty" \| "skipped", files?, reason? }] }` — a forecast: nothing was rebased, no hooks/carry keys; `files` (string[]) lists the likely-conflicted paths when merge-tree hit conflicts (also present on a `dirty` repo whose committed tips would conflict); `reason`: `"no worktree"`, `"branch missing"`, `"cannot resolve origin's default branch"`, or the per-repo error; exits 0 even when conflicts are forecast |
480
+ | `ship` | `{ task, base, repos: [{ name, branch, base?, pushed: bool, pr?: { number, url, action: "created" \| "updated" }, status: "shipped" \| "skipped" \| "failed", reason?, error? }], hooks: [{ event, repo, exit }] }` — top-level `base` is the run default; a stacked child carries the per-entry `base` its PR targets (its parent's branch); `reason` (skip): `"nothing to ship"`, `"uncommitted changes"`, `"parent <token> not on remote — ship it first"` (a stacked child whose parent isn't pushed yet), `"cannot resolve base — pass --base <ref>"`, `"pre-ship hook failed"`; `error` set when `status` is `"failed"` (push/`gh` failure); parents are shipped before children; `pr` present unless `--no-pr`; `action` is `"updated"` when the PR already existed (push-only, not edited); exits non-zero if any repo `failed` |
481
+ | `close` | `{ task, forced: bool, repos: [{ name, status: "closed" \| "skipped", reason? }], hooks: [{ event, repo, exit }], carry: [{ repo, modified }] }` — `reason`: `"uncommitted changes"`, `"unmerged commits"`, `"pre-close hook failed"`; `carry` lists carried files modified inside the task (warn-only — their edits are lost with the worktree) |
482
+ | `prune` | `{ forced: bool, tasks: [{ task, status: "pruned" \| "kept", reason? }] }` — `reason`: `"dirty"`, `"unmerged"`, or the failure message; when `forced` is false a `"pruned"` status means a preview candidate (nothing removed yet) |
483
+
484
+ The `note` object (in `status` and `open`) is the parsed `ubertask.yml` plus its
485
+ mtime: `{ goal, repos, tickets, decisions, blockers, mtime }`, where `decisions`
486
+ and `blockers` are `{ note, repo? }[]` and `mtime` is epoch-ms. It is omitted
487
+ when the task has no note file.
@@ -0,0 +1,36 @@
1
+ # Working in this uberepo workspace
2
+
3
+ Several git repos managed together via the `uberepo` CLI. Each *task* gets its own
4
+ worktree in every repo — work across them at once, switch tasks by switching dirs,
5
+ never by hand. (A repo can carry more than one branch in a task via a `repo@alias`
6
+ participant — see Layout.) First move: `uberepo status` + `uberepo sources`.
7
+
8
+ ## Layout
9
+
10
+ <workspace>/
11
+ ├── uberepo.json # the registered repositories
12
+ ├── source/<repo>/ # canonical clone — read-only, don't work here
13
+ ├── tasks/<task>/<name>/ # per-task worktree, on branch task/<task> — work here
14
+ └── tasks/<task>/ubertask.yml # task handoff note — keep it current
15
+
16
+ `<name>` is the participant: a bare repo (`web`, branch `task/<task>`) or a
17
+ `repo@alias` token (`web@auth`, branch `task/<task>@auth`) when a repo carries
18
+ several branches in one task. The folder is flat one level either way, and all of
19
+ a repo's participants share its one `source/<repo>` clone.
20
+
21
+ ## Rules
22
+
23
+ - **Work in `tasks/<task>/`, never in `source/`.** That's where edits, commits, and
24
+ pushes happen — per repo (uberepo doesn't push for you). `source/` is the shared base.
25
+ - **Don't manage repos or worktrees by hand** — use the commands, not `git clone` /
26
+ `git worktree` / a hand-edited `uberepo.json`. They guard unsaved work; raw git doesn't.
27
+ - **Each repo speaks for itself** — inside a repo's worktree, follow that repo's own
28
+ `AGENTS.md` / `README`. This file only covers the workspace.
29
+ - **Keep `tasks/<task>/ubertask.yml` current** — set `goal` when you start the task,
30
+ append `decisions` / `blockers` as they come up; don't record progress or next-steps
31
+ (git shows those). The `using-uberepo` skill carries the full reader/writer contract.
32
+
33
+ ## Going deeper
34
+
35
+ `uberepo --help` lists every command and flag. Claude Code: the `using-uberepo` skill
36
+ in `.claude/skills/` carries the full lifecycle (open → sync → close), recovery, and sharing.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,5 @@
1
+ # uberepo workspace — source clones and task worktrees rebuild from uberepo.json
2
+ source/
3
+ tasks/
4
+
5
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ # ubertask.yml — durable task note. The "why"; git holds the "what".
2
+ goal: |
3
+ <one line: what done looks like & why>
4
+
5
+ repos: []
6
+
7
+ branches: {}
8
+
9
+ tickets: []
10
+
11
+ decisions: []
12
+
13
+ blockers: []