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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/uberepo.mjs +21 -0
- package/dist/cli.mjs +7749 -0
- package/package.json +40 -0
- package/template/.agents/skills/using-uberepo/SKILL.md +142 -0
- package/template/.agents/skills/using-uberepo/reference.md +487 -0
- package/template/.claude/skills/using-uberepo/SKILL.md +142 -0
- package/template/.claude/skills/using-uberepo/reference.md +487 -0
- package/template/AGENTS.md +36 -0
- package/template/CLAUDE.md +1 -0
- package/template/gitignore +5 -0
- package/template/ubertask.yml +13 -0
|
@@ -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
|