@ulysses-ai/create-workspace 0.18.0-beta.0 → 0.20.0-beta.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/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/lib/upgrade.mjs +20 -0
- package/lib/upgrade.test.mjs +119 -0
- package/package.json +3 -3
- package/template/CLAUDE.md.tmpl +3 -0
- package/template/{.claude → _claude}/lib/freshness.mjs +20 -7
- package/template/_claude/lib/registry-check.mjs +172 -0
- package/template/{.claude → _claude}/rules/forge-operations.md +6 -0
- package/template/{.claude → _claude}/rules/memory-guidance.md +4 -0
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +25 -14
- package/template/{.claude → _claude}/scripts/chat-record.mjs +62 -21
- package/template/_claude/scripts/classify-update.mjs +117 -0
- package/template/{.claude → _claude}/scripts/cleanup-work-session.mjs +4 -2
- package/template/{.claude → _claude}/scripts/context-footprint.mjs +139 -30
- package/template/{.claude → _claude}/scripts/forges/github.mjs +2 -1
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +5 -4
- package/template/_claude/scripts/merge-mode.mjs +61 -0
- package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +7 -1
- package/template/_claude/scripts/migrate-claude-md-freshness-include.mjs +55 -0
- package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +19 -8
- package/template/{.claude → _claude}/scripts/migrate-sessions.mjs +444 -121
- package/template/_claude/scripts/task-pr.mjs +555 -0
- package/template/{.claude → _claude}/scripts/task-worktree.mjs +26 -16
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/complete-work/SKILL.md +25 -77
- package/template/{.claude → _claude}/skills/context-placement/SKILL.md +8 -5
- package/template/{.claude → _claude}/skills/goal-driven-work/SKILL.md +1 -1
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +49 -17
- package/template/{.claude → _claude}/skills/migrate-sessions/SKILL.md +17 -5
- package/template/{.claude → _claude}/skills/release/SKILL.md +6 -2
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +5 -5
- package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +20 -14
- package/template/_claude/skills/workspace-update/SKILL.md +177 -0
- package/template/_gitignore +3 -0
- package/template/workspace.json.tmpl +1 -1
- package/template/.claude/lib/registry-check.mjs +0 -106
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +0 -30
- package/template/.claude/skills/workspace-update/SKILL.md +0 -134
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/_utils.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/repo-write-detection.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/subagent-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/lib/session-frontmatter.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/coherent-revisions.md +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/git-conventions.md +0 -0
- /package/template/{.claude → _claude}/rules/goal-driven-work.md +0 -0
- /package/template/{.claude → _claude}/rules/honest-pushback.md +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/work-item-tracking.md +0 -0
- /package/template/{.claude → _claude}/rules/workspace-structure.md +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/workspace-diagnostics.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/pause-work/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /package/template/{.mcp.json → _mcp.json} +0 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PR creation and merge for the task lifecycle (gh:163), including local
|
|
3
|
+
// mode for repos with no forge to push to (gh:173).
|
|
4
|
+
//
|
|
5
|
+
// The task path of /complete-work used to end with two hand-written
|
|
6
|
+
// JavaScript blocks — one to push and open a PR per repo, one to merge them
|
|
7
|
+
// in order and close the issue. Every run re-typed them, and every
|
|
8
|
+
// re-typing was a fresh chance to drop a guard: origins must be parsed
|
|
9
|
+
// before anything is pushed, body files must exist for every repo that
|
|
10
|
+
// gets a PR, and the workspace PR must never merge ahead of the project
|
|
11
|
+
// PRs it describes. This script is those blocks written once.
|
|
12
|
+
//
|
|
13
|
+
// It runs from the launcher and owns only this slice: the rebase and the
|
|
14
|
+
// drawer routing happen in the skill's earlier steps, and teardown stays
|
|
15
|
+
// in task-worktree.mjs --remove.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node task-pr.mjs --create --root <launcher> --branch <branch>
|
|
19
|
+
// [--work-item gh:N] [--chat <name> | --repo <r> ...]
|
|
20
|
+
// --body-file <forge-repo>=<path> ... [--out <file>]
|
|
21
|
+
// [--force-with-lease]
|
|
22
|
+
// node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
|
|
23
|
+
// [--work-item gh:N]
|
|
24
|
+
//
|
|
25
|
+
// Every repo of the task resolves to a merge mode (mergeModeFor below):
|
|
26
|
+
// "forge" — its origin is a forge-hosted owner/name — or "local" — no
|
|
27
|
+
// origin at all, or an explicit "local" override in workspace.json
|
|
28
|
+
// (repos.{repo}.merge / workspace.merge), the escape hatch for a clone
|
|
29
|
+
// whose origin is a third-party upstream nobody here may push to. An
|
|
30
|
+
// origin that is neither stops the run with the override spelled out.
|
|
31
|
+
//
|
|
32
|
+
// --create resolves the task's repos from the chat record's entries for
|
|
33
|
+
// the branch (--chat) or from repeated --repo flags, skips repos whose
|
|
34
|
+
// branch has no commits over the base — origin/{default} for a forge
|
|
35
|
+
// repo, the local {default} for a local repo, whose origin ref never
|
|
36
|
+
// advances — (reported as empty), pushes each remaining forge branch, and
|
|
37
|
+
// opens one PR per forge repo through a per-repo forge — a local repo is
|
|
38
|
+
// neither pushed nor PR'd, only recorded as a local entry for --merge (a
|
|
39
|
+
// body file is required per forge repo alone; one given for a local repo
|
|
40
|
+
// is ignored). The PR title is the linked issue's title when --work-item
|
|
41
|
+
// is given, else the branch's first commit subject; the body is the
|
|
42
|
+
// repo's body file with `Closes <ref>` appended. Prints
|
|
43
|
+
// `{ prs, empty, pushed }` — prs holds forge entries (mode "forge") and
|
|
44
|
+
// local entries (mode "local") alike, every entry carrying its commit
|
|
45
|
+
// count — and, with --out, writes the same JSON to a file — a mid-run
|
|
46
|
+
// failure still writes what has landed so far, so the state survives the
|
|
47
|
+
// error. A repo that already has an open PR for the branch gets it
|
|
48
|
+
// reused, so a re-run never opens a duplicate.
|
|
49
|
+
//
|
|
50
|
+
// --merge finishes every entry in the file: forge PRs first (squash,
|
|
51
|
+
// delete branch) and local branches as a `git merge --ff-only` in the
|
|
52
|
+
// repo's source clone — repos/{repo}, or the launcher itself for "." —
|
|
53
|
+
// which must sit clean on its default branch, and whose task branch must
|
|
54
|
+
// fast-forward or be rebased by hand. The project repos merge first, the
|
|
55
|
+
// workspace repo only when every project merge succeeded; a PR the forge
|
|
56
|
+
// reports as already MERGED and a local branch already contained in the
|
|
57
|
+
// default branch both count as done, which is what makes re-running after
|
|
58
|
+
// a partial failure safe. An empty or malformed PRs file — or one naming
|
|
59
|
+
// a repo other than "." or a plain repo name, since a local entry's repo
|
|
60
|
+
// becomes a path — is refused outright: no entries means nothing to merge
|
|
61
|
+
// and nothing to close. Once everything is merged the launcher is pulled
|
|
62
|
+
// --ff-only — only when it sits on the workspace default branch (else
|
|
63
|
+
// pullSkipped), never when "." was itself merged locally (the launcher
|
|
64
|
+
// already has that work), when the workspace repo is local (no forge
|
|
65
|
+
// merge happened that a pull could fetch), or when the launcher branch
|
|
66
|
+
// has no upstream to pull from — pullSkipped names which — and a failed
|
|
67
|
+
// pull is reported as pullFailed in the JSON rather than an error,
|
|
68
|
+
// because the merges stand and the issue still closes — and then the
|
|
69
|
+
// linked issue closes with a Merged: comment, but only when --work-item
|
|
70
|
+
// is given AND a tracker is configured; with no tracker the JSON reports
|
|
71
|
+
// closed: null and closeSkipped. On a merge failure it stops and names
|
|
72
|
+
// what is still open.
|
|
73
|
+
|
|
74
|
+
import '../lib/require-node.mjs';
|
|
75
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
76
|
+
import { resolve, dirname } from 'node:path';
|
|
77
|
+
import { spawnSync } from 'node:child_process';
|
|
78
|
+
import { fileURLToPath } from 'node:url';
|
|
79
|
+
import { taskWorktreePath, defaultBranchFor } from './task-worktree.mjs';
|
|
80
|
+
import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
81
|
+
import { readRecord } from './chat-record.mjs';
|
|
82
|
+
import { createForge } from './forges/interface.mjs';
|
|
83
|
+
import { createTracker } from './trackers/interface.mjs';
|
|
84
|
+
|
|
85
|
+
function isMainModule(metaUrl) {
|
|
86
|
+
if (!process.argv[1]) return false;
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
89
|
+
} catch { return false; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `workspace.forge: false` is an explicit opt-out; spreading it into a
|
|
93
|
+
// per-repo config would silently produce a default GitHub forge instead.
|
|
94
|
+
function assertForgeEnabled(ws) {
|
|
95
|
+
if (ws?.workspace?.forge === false) {
|
|
96
|
+
throw new Error('workspace.forge is false — forge operations are disabled here. Nothing was pushed; open the PR by hand.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Branch names become refs, refspecs, and (via the slug) paths; git's own
|
|
101
|
+
// format check is the authority, and its --branch variant also rejects
|
|
102
|
+
// names a later git call could mistake for an option. It needs no
|
|
103
|
+
// repository, so it runs before any other git call this script makes.
|
|
104
|
+
function assertBranchName(gitFn, branch) {
|
|
105
|
+
const res = gitFn('git', ['check-ref-format', '--branch', branch], { encoding: 'utf8' });
|
|
106
|
+
if (res.error || res.status !== 0) throw new Error(`invalid branch name: ${branch}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The --prs file may have been written by a BOM-emitting writer (PowerShell
|
|
110
|
+
// redirection among them); JSON.parse chokes on the marker, so strip it.
|
|
111
|
+
function stripBom(text) {
|
|
112
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeOut(path, payload) {
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gitCheck(gitFn, cwd, args) {
|
|
121
|
+
const res = gitFn('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
|
122
|
+
if (res.error) throw new Error(`git: ${res.error.message}`);
|
|
123
|
+
return res;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function gitOut(gitFn, cwd, args) {
|
|
127
|
+
const res = gitCheck(gitFn, cwd, args);
|
|
128
|
+
if (res.status !== 0) {
|
|
129
|
+
throw new Error(`git -C ${cwd} ${args.join(' ')} failed: ${String(res.stderr || '').trim()}`);
|
|
130
|
+
}
|
|
131
|
+
return String(res.stdout || '').trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The merge base for "is this branch empty" and for the fallback PR title.
|
|
135
|
+
// A forge repo counts against origin/{default} — the truth the PR will
|
|
136
|
+
// merge against — with the local branch as the fallback for a repo whose
|
|
137
|
+
// remote-tracking ref is missing. A local-mode repo counts against the
|
|
138
|
+
// local {default}, where its merges actually land: its origin ref never
|
|
139
|
+
// advances, so counting against it would re-count every already-merged
|
|
140
|
+
// commit (and misjudge every follow-up task as non-empty).
|
|
141
|
+
function baseRefFor(gitFn, target, branch) {
|
|
142
|
+
const { worktree, defaultBranch, mode } = target;
|
|
143
|
+
const refs = mode === 'local'
|
|
144
|
+
? [defaultBranch, `origin/${defaultBranch}`]
|
|
145
|
+
: [`origin/${defaultBranch}`, defaultBranch];
|
|
146
|
+
for (const ref of refs) {
|
|
147
|
+
if (gitCheck(gitFn, worktree, ['rev-parse', '--verify', '--quiet', ref]).status === 0) return ref;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function commitsOverBase(gitFn, target, branch) {
|
|
153
|
+
const base = baseRefFor(gitFn, target, branch);
|
|
154
|
+
// No base ref at all: the branch cannot be proven empty, so it proceeds.
|
|
155
|
+
if (!base) return null;
|
|
156
|
+
return parseInt(gitOut(gitFn, target.worktree, ['rev-list', '--count', `${base}..${branch}`]), 10) || 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function firstCommitSubject(gitFn, target, branch) {
|
|
160
|
+
const base = baseRefFor(gitFn, target, branch);
|
|
161
|
+
const range = base ? `${base}..${branch}` : branch;
|
|
162
|
+
const subjects = gitOut(gitFn, target.worktree, ['log', '--reverse', '--format=%s', range])
|
|
163
|
+
.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
164
|
+
if (subjects.length > 0) return subjects[0].trim();
|
|
165
|
+
return gitOut(gitFn, target.worktree, ['log', '-1', '--format=%s', branch]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function pushBranch(gitFn, worktree, repo, branch, { forceWithLease = false } = {}) {
|
|
169
|
+
// `--` ends option parsing so the refspec can never read as a flag — git
|
|
170
|
+
// accepts it on push. It is deliberately NOT used on the rev-parse /
|
|
171
|
+
// rev-list / log calls, where git would flip the argument to a path.
|
|
172
|
+
const res = gitCheck(gitFn, worktree, [
|
|
173
|
+
'push', '-u', 'origin', ...(forceWithLease ? ['--force-with-lease'] : []), '--', branch,
|
|
174
|
+
]);
|
|
175
|
+
if (res.status === 0) return;
|
|
176
|
+
const stderr = String(res.stderr || '').trim();
|
|
177
|
+
if (!forceWithLease && /non-fast-forward|fetch first/i.test(stderr)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`repo "${repo}": push rejected as non-fast-forward — the branch already exists on origin and the rebase rewrote it. Confirm and re-run with --force-with-lease; this script never forces on its own.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`repo "${repo}": git push failed: ${stderr}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveTaskRepos(rootDir, { chat, branch, repos }) {
|
|
186
|
+
if (repos.length > 0) return [...new Set(repos)];
|
|
187
|
+
if (!chat) {
|
|
188
|
+
throw new Error("--create needs the task's repos: pass --chat <name> (its record entries for the branch) or repeat --repo");
|
|
189
|
+
}
|
|
190
|
+
const rec = readRecord(rootDir, chat);
|
|
191
|
+
const entries = (rec?.tasks ?? []).filter((t) => t.branch === branch && t.repo);
|
|
192
|
+
if (entries.length === 0) {
|
|
193
|
+
throw new Error(`chat record "${chat}" has no task entries for branch ${branch} — pass --repo explicitly`);
|
|
194
|
+
}
|
|
195
|
+
return [...new Set(entries.map((t) => t.repo))];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function createPrs(args, deps) {
|
|
199
|
+
assertBranchName(deps.gitFn, args.branch);
|
|
200
|
+
const rootDir = resolve(args.root);
|
|
201
|
+
const ws = readWorkspace(rootDir);
|
|
202
|
+
|
|
203
|
+
const targets = resolveTaskRepos(rootDir, args).map((repo) => {
|
|
204
|
+
const worktree = taskWorktreePath(rootDir, repo, args.branch);
|
|
205
|
+
if (!existsSync(worktree)) {
|
|
206
|
+
throw new Error(`no task worktree at ${worktree} for repo "${repo}" — create it with task-worktree.mjs --create`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
repo, worktree, isWorkspace: repo === WORKSPACE_REPO,
|
|
210
|
+
defaultBranch: defaultBranchFor(rootDir, repo, deps.gitFn),
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Mode first, because the commit count itself depends on it: a local
|
|
215
|
+
// repo counts against its local default branch, not the origin ref that
|
|
216
|
+
// never advances (gh:173).
|
|
217
|
+
for (const t of targets) t.mode = mergeModeFor(rootDir, t.repo, { gitFn: deps.gitFn });
|
|
218
|
+
for (const t of targets) t.commits = commitsOverBase(deps.gitFn, t, args.branch);
|
|
219
|
+
const empty = targets.filter((t) => t.commits === 0).map((t) => t.repo);
|
|
220
|
+
const active = targets.filter((t) => t.commits !== 0);
|
|
221
|
+
|
|
222
|
+
// Every guard runs before the first push: a repo whose origin can neither
|
|
223
|
+
// host a PR nor opt out with "local", or whose body file is missing, must
|
|
224
|
+
// stop the whole task with nothing pushed and nothing half-opened. The
|
|
225
|
+
// same pass splits the task: local repos are recorded for --merge instead
|
|
226
|
+
// of being pushed. An empty repo is exempt from the origin check — there
|
|
227
|
+
// is nothing to merge however its remote is shaped.
|
|
228
|
+
for (const t of active) {
|
|
229
|
+
if (t.mode !== null) continue;
|
|
230
|
+
const url = gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin']);
|
|
231
|
+
const setting = t.isWorkspace ? 'workspace.merge' : `repos.${t.repo}.merge`;
|
|
232
|
+
throw new Error(`repo "${t.repo}": origin (${url}) is not a forge-hosted repository — stopped before pushing anything. Set ${setting} to "local" in workspace.json to merge this repo without a forge, or complete it under the session model.`);
|
|
233
|
+
}
|
|
234
|
+
const forgeActive = active.filter((t) => t.mode === 'forge');
|
|
235
|
+
// Only the forge half of the task needs a forge; an all-local task must
|
|
236
|
+
// still complete with forge operations disabled.
|
|
237
|
+
if (forgeActive.length > 0) assertForgeEnabled(ws);
|
|
238
|
+
for (const t of forgeActive) {
|
|
239
|
+
Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin'])));
|
|
240
|
+
}
|
|
241
|
+
for (const t of forgeActive) {
|
|
242
|
+
const bodyFile = args.bodyFiles.get(t.repo);
|
|
243
|
+
if (!bodyFile) {
|
|
244
|
+
throw new Error(`repo "${t.repo}" has commits to merge but no --body-file — one is required for every repo that gets a PR`);
|
|
245
|
+
}
|
|
246
|
+
if (!existsSync(bodyFile)) {
|
|
247
|
+
throw new Error(`repo "${t.repo}": body file not found: ${bodyFile}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let issueTitle = null;
|
|
252
|
+
let issueRefs = null;
|
|
253
|
+
if (args.workItem && ws.workspace?.tracker) {
|
|
254
|
+
const tracker = deps.trackerFactory(ws.workspace.tracker);
|
|
255
|
+
issueTitle = (await tracker.getIssue(args.workItem)).title;
|
|
256
|
+
issueRefs = new Map(forgeActive.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Local entries are recorded up front, before anything can fail: they are
|
|
260
|
+
// the run's only side effect for those repos, and losing one to a later
|
|
261
|
+
// forge failure would leave --merge unable to finish the task.
|
|
262
|
+
const prs = active
|
|
263
|
+
.filter((t) => t.mode === 'local')
|
|
264
|
+
.map((t) => ({
|
|
265
|
+
repo: t.repo, mode: 'local', branch: args.branch, base: t.defaultBranch,
|
|
266
|
+
worktree: t.worktree, commits: t.commits,
|
|
267
|
+
}));
|
|
268
|
+
const pushed = [];
|
|
269
|
+
try {
|
|
270
|
+
for (const t of forgeActive) {
|
|
271
|
+
pushBranch(deps.gitFn, t.worktree, t.repo, args.branch, { forceWithLease: args.forceWithLease });
|
|
272
|
+
pushed.push(t.repo);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const t of forgeActive) {
|
|
276
|
+
const forge = deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${t.owner}/${t.name}` });
|
|
277
|
+
// Idempotency: a re-run must not open a second PR for a branch that
|
|
278
|
+
// already has one open against the same base. Reuse it as-is —
|
|
279
|
+
// re-titling or re-bodying an existing PR is a decision, not a
|
|
280
|
+
// default, and the one already open is the one reviewers watch.
|
|
281
|
+
const existing = (await forge.prList({ state: 'open', head: args.branch, base: t.defaultBranch }))
|
|
282
|
+
.find((p) => p.headRefName === args.branch && p.baseRefName === t.defaultBranch);
|
|
283
|
+
const title = issueTitle ?? firstCommitSubject(deps.gitFn, t, args.branch);
|
|
284
|
+
let body = readFileSync(args.bodyFiles.get(t.repo), 'utf8').replace(/\s*$/, '');
|
|
285
|
+
if (issueRefs) body = `${body}\n\nCloses ${issueRefs.get(t.repo)}\n`;
|
|
286
|
+
const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
|
|
287
|
+
prs.push({
|
|
288
|
+
repo: t.repo, mode: 'forge', owner: t.owner, name: t.name,
|
|
289
|
+
number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace, commits: t.commits,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// A failure after the first push must not lose the run's state: the
|
|
294
|
+
// pushes and PRs that landed go to --out so a re-run (or a --merge of
|
|
295
|
+
// what exists) starts from reality. Before the first push nothing has
|
|
296
|
+
// happened, and an earlier run's file stays untouched rather than
|
|
297
|
+
// being clobbered with a no-op result.
|
|
298
|
+
if (args.out && (pushed.length > 0 || prs.length > 0)) {
|
|
299
|
+
writeOut(args.out, { prs, empty, pushed });
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
const result = { prs, empty, pushed };
|
|
304
|
+
if (args.out) writeOut(args.out, result);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Merge one local-mode entry in its repo's source clone — repos/{repo} for
|
|
309
|
+
// a project repo, the launcher itself for ".". The clone must be sitting
|
|
310
|
+
// clean on its default branch: merging anywhere else would strand the
|
|
311
|
+
// checkout or swallow uncommitted work. --ff-only because a task branch
|
|
312
|
+
// that diverged from the local default needs a rebase first — deciding how
|
|
313
|
+
// to combine is the user's, not this script's. A branch already contained
|
|
314
|
+
// in the default branch counts as merged, which is what makes a re-run
|
|
315
|
+
// after a partial failure safe.
|
|
316
|
+
function mergeLocalEntry(gitFn, rootDir, entry) {
|
|
317
|
+
const repoDir = repoDirFor(rootDir, entry.repo);
|
|
318
|
+
const base = entry.base || defaultBranchFor(rootDir, entry.repo, gitFn);
|
|
319
|
+
const onBranch = gitOut(gitFn, repoDir, ['branch', '--show-current']);
|
|
320
|
+
if (onBranch !== base) {
|
|
321
|
+
throw new Error(`its source clone is on ${onBranch || 'a detached HEAD'}, not ${base} — check out ${base} and re-run --merge`);
|
|
322
|
+
}
|
|
323
|
+
if (String(gitCheck(gitFn, repoDir, ['status', '--porcelain']).stdout || '').trim() !== '') {
|
|
324
|
+
throw new Error('its source clone has uncommitted changes — commit or stash them and re-run --merge');
|
|
325
|
+
}
|
|
326
|
+
if (gitCheck(gitFn, repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${entry.branch}`]).status !== 0) {
|
|
327
|
+
throw new Error(`branch ${entry.branch} not found in its source clone — nothing to merge`);
|
|
328
|
+
}
|
|
329
|
+
if (gitCheck(gitFn, repoDir, ['merge-base', '--is-ancestor', entry.branch, base]).status === 0) {
|
|
330
|
+
return { alreadyMerged: true };
|
|
331
|
+
}
|
|
332
|
+
const res = gitCheck(gitFn, repoDir, ['merge', '--ff-only', entry.branch]);
|
|
333
|
+
if (res.status !== 0) {
|
|
334
|
+
throw new Error(`${entry.branch} is not fast-forwardable onto ${base} — rebase the task branch onto ${base} and re-run --merge`);
|
|
335
|
+
}
|
|
336
|
+
return { alreadyMerged: false };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function mergePrs(args, deps) {
|
|
340
|
+
const rootDir = resolve(args.root);
|
|
341
|
+
const ws = readWorkspace(rootDir);
|
|
342
|
+
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(stripBom(readFileSync(args.prs, 'utf-8')));
|
|
346
|
+
} catch (err) {
|
|
347
|
+
throw new Error(`cannot read PRs file ${args.prs}: ${err.message}`);
|
|
348
|
+
}
|
|
349
|
+
// An empty or malformed file is a refusal, not a no-op: with nothing to
|
|
350
|
+
// merge there is nothing that earns an issue close, and "Merged: " with
|
|
351
|
+
// no URLs would tell the tracker a lie.
|
|
352
|
+
if (!parsed || !Array.isArray(parsed.prs)) {
|
|
353
|
+
throw new Error(`PRs file ${args.prs} is malformed — expected the JSON from a --create run ({ prs: [...] })`);
|
|
354
|
+
}
|
|
355
|
+
const prs = parsed.prs;
|
|
356
|
+
if (prs.length === 0) {
|
|
357
|
+
throw new Error(`PRs file ${args.prs} lists no PRs — nothing to merge and nothing to close; --merge refuses to close an issue on an empty merge`);
|
|
358
|
+
}
|
|
359
|
+
// Entries written before modes existed carry no `mode`; they are forge
|
|
360
|
+
// PRs, the only kind --create used to emit. A local entry's repo becomes
|
|
361
|
+
// a path under repos/, so the file cannot be allowed to name anything
|
|
362
|
+
// but "." or a plain single segment.
|
|
363
|
+
const modeOf = (p) => p?.mode ?? 'forge';
|
|
364
|
+
const REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
365
|
+
for (const p of prs) {
|
|
366
|
+
if (!p) throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
|
|
367
|
+
if (p.repo !== WORKSPACE_REPO
|
|
368
|
+
&& (typeof p.repo !== 'string' || p.repo === '..' || !REPO_NAME_RE.test(p.repo))) {
|
|
369
|
+
throw new Error(`PRs file entry has an invalid repo name: ${JSON.stringify(p.repo)} — expected "." or a plain repo name`);
|
|
370
|
+
}
|
|
371
|
+
if (modeOf(p) === 'local') {
|
|
372
|
+
if (!p.branch) throw new Error(`PRs file entry is missing branch: ${JSON.stringify(p)}`);
|
|
373
|
+
} else if (!p.owner || !p.name || !p.id) {
|
|
374
|
+
throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (prs.some((p) => modeOf(p) === 'forge')) assertForgeEnabled(ws);
|
|
378
|
+
const forgeFor = (p) => deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${p.owner}/${p.name}` });
|
|
379
|
+
|
|
380
|
+
// State first, so a re-run knows what an earlier run already finished.
|
|
381
|
+
// A PR the forge reports MERGED is done; anything else is offered to
|
|
382
|
+
// prMerge, which reports its own failure if the PR cannot merge. If the
|
|
383
|
+
// view itself fails, fall through to the merge attempt — that path
|
|
384
|
+
// produces the honest error when something is genuinely wrong.
|
|
385
|
+
const alreadyMerged = new Set();
|
|
386
|
+
for (const p of prs) {
|
|
387
|
+
if (modeOf(p) !== 'forge') continue;
|
|
388
|
+
try {
|
|
389
|
+
const view = await forgeFor(p).prView({ id: p.id });
|
|
390
|
+
if (view?.state === 'MERGED') alreadyMerged.add(p.id);
|
|
391
|
+
} catch { /* state unknown — the merge attempt below decides */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Ordering rule: the project repos merge first — forge PRs and local
|
|
395
|
+
// branches alike, in file order — and the workspace repo last, only when
|
|
396
|
+
// every project merge succeeded: the workspace branch's promoted context
|
|
397
|
+
// describes the project merges and must never land ahead of them.
|
|
398
|
+
const merged = [];
|
|
399
|
+
const failures = [];
|
|
400
|
+
const workspaceEntry = prs.find((p) => p.repo === WORKSPACE_REPO) ?? null;
|
|
401
|
+
const mergeOne = async (p) => {
|
|
402
|
+
if (modeOf(p) === 'local') {
|
|
403
|
+
await mergeLocalEntry(deps.gitFn, rootDir, p);
|
|
404
|
+
} else if (!alreadyMerged.has(p.id)) {
|
|
405
|
+
await forgeFor(p).prMerge({ id: p.id, strategy: 'squash', deleteBranch: true });
|
|
406
|
+
}
|
|
407
|
+
merged.push(p);
|
|
408
|
+
};
|
|
409
|
+
for (const p of prs.filter((x) => x !== workspaceEntry)) {
|
|
410
|
+
try { await mergeOne(p); } catch (err) { failures.push(`${p.repo}: ${err.message}`); }
|
|
411
|
+
}
|
|
412
|
+
if (failures.length === 0 && workspaceEntry) {
|
|
413
|
+
try { await mergeOne(workspaceEntry); } catch (err) { failures.push(`${workspaceEntry.repo}: ${err.message}`); }
|
|
414
|
+
}
|
|
415
|
+
if (failures.length > 0) {
|
|
416
|
+
const open = prs.filter((p) => !merged.includes(p)).map((p) => `${p.repo}: ${p.url ?? p.branch}`);
|
|
417
|
+
throw new Error(
|
|
418
|
+
`merge stopped (${failures.join('; ')}). Still open: ${open.join(', ')}. The workspace repo is never merged ahead of the project repos — fix the failure and re-run --merge.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Every entry in the file is merged from here on. The launcher pull is a
|
|
423
|
+
// convenience, not a gate: it happens only when the launcher sits on the
|
|
424
|
+
// workspace default branch and "." itself was not merged locally — that
|
|
425
|
+
// merge ran in the launcher, so it already has the work — a failure is a
|
|
426
|
+
// reported flag rather than an error (the merges stand and the issue
|
|
427
|
+
// still closes), and the user is told to pull by hand before teardown.
|
|
428
|
+
const result = {
|
|
429
|
+
merged: merged.map((p) => (modeOf(p) === 'local'
|
|
430
|
+
? { repo: p.repo, mode: 'local', branch: p.branch, base: p.base }
|
|
431
|
+
: { repo: p.repo, mode: 'forge', number: p.number, url: p.url })),
|
|
432
|
+
closed: null,
|
|
433
|
+
};
|
|
434
|
+
if (merged.some((p) => p.repo === WORKSPACE_REPO && modeOf(p) === 'local')) {
|
|
435
|
+
result.pullSkipped = 'workspace merged locally';
|
|
436
|
+
} else {
|
|
437
|
+
const launcherBranch = gitOut(deps.gitFn, rootDir, ['branch', '--show-current']) || '(detached HEAD)';
|
|
438
|
+
if (launcherBranch !== defaultBranchFor(rootDir, WORKSPACE_REPO, deps.gitFn)) {
|
|
439
|
+
result.pullSkipped = `launcher on ${launcherBranch}`;
|
|
440
|
+
} else if (mergeModeFor(rootDir, WORKSPACE_REPO, { gitFn: deps.gitFn }) === 'local') {
|
|
441
|
+
// The workspace repo merges into the launcher itself — or was not part
|
|
442
|
+
// of this task at all; either way no forge merge happened that a pull
|
|
443
|
+
// could fetch.
|
|
444
|
+
result.pullSkipped = 'workspace repo is local';
|
|
445
|
+
} else if (gitCheck(deps.gitFn, rootDir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).status !== 0) {
|
|
446
|
+
result.pullSkipped = 'launcher has no upstream';
|
|
447
|
+
} else {
|
|
448
|
+
const pull = gitCheck(deps.gitFn, rootDir, ['pull', '--ff-only']);
|
|
449
|
+
if (pull.status !== 0) result.pullFailed = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The close needs both halves: a work item to close, and a tracker to
|
|
454
|
+
// close it on. A workspace without a tracker still merges everything —
|
|
455
|
+
// it just reports the skipped close instead of pretending one happened.
|
|
456
|
+
if (args.workItem) {
|
|
457
|
+
if (!ws.workspace?.tracker) {
|
|
458
|
+
result.closeSkipped = 'no tracker configured';
|
|
459
|
+
} else {
|
|
460
|
+
const tracker = deps.trackerFactory(ws.workspace.tracker);
|
|
461
|
+
const parts = merged.map((p) => (modeOf(p) === 'local' ? `${p.repo} ${p.branch}->${p.base}` : p.url));
|
|
462
|
+
await tracker.closeIssue(args.workItem, { comment: `Merged: ${parts.join(' ')}` });
|
|
463
|
+
result.closed = args.workItem;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const MODE_FLAGS = new Set(['--create', '--merge']);
|
|
470
|
+
const VALUE_FLAGS = new Set(['--root', '--branch', '--work-item', '--chat', '--prs', '--out']);
|
|
471
|
+
|
|
472
|
+
function parseArgs(argv) {
|
|
473
|
+
const args = {
|
|
474
|
+
root: '.', mode: null, branch: null, workItem: null, chat: null,
|
|
475
|
+
repos: [], bodyFiles: new Map(), prs: null, out: null, forceWithLease: false,
|
|
476
|
+
};
|
|
477
|
+
const rest = argv.slice(2);
|
|
478
|
+
const value = (i, flag) => {
|
|
479
|
+
const v = rest[i + 1];
|
|
480
|
+
if (v === undefined || v.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
481
|
+
return v;
|
|
482
|
+
};
|
|
483
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
484
|
+
const a = rest[i];
|
|
485
|
+
if (MODE_FLAGS.has(a)) {
|
|
486
|
+
if (args.mode) throw new Error(`only one of --create, --merge may be given (already have --${args.mode})`);
|
|
487
|
+
args.mode = a.slice(2);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (a === '--repo') { args.repos.push(value(i, a)); i += 1; continue; }
|
|
491
|
+
if (a === '--body-file') {
|
|
492
|
+
const v = value(i, a);
|
|
493
|
+
const eq = v.indexOf('=');
|
|
494
|
+
if (eq <= 0) throw new Error(`--body-file expects <repo>=<path>, got: ${v}`);
|
|
495
|
+
args.bodyFiles.set(v.slice(0, eq), v.slice(eq + 1));
|
|
496
|
+
i += 1;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (a === '--force-with-lease') { args.forceWithLease = true; continue; }
|
|
500
|
+
if (VALUE_FLAGS.has(a)) {
|
|
501
|
+
args[a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value(i, a);
|
|
502
|
+
i += 1;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
throw new Error(`unknown argument: ${a}`);
|
|
506
|
+
}
|
|
507
|
+
if (!args.mode) throw new Error('one of --create, --merge is required');
|
|
508
|
+
if (args.mode === 'create') {
|
|
509
|
+
if (!args.branch) throw new Error('--create requires --branch');
|
|
510
|
+
if (!args.chat && args.repos.length === 0) throw new Error('--create requires --chat or at least one --repo');
|
|
511
|
+
if (args.prs) throw new Error('--prs is only valid with --merge');
|
|
512
|
+
} else {
|
|
513
|
+
if (!args.prs) throw new Error('--merge requires --prs');
|
|
514
|
+
for (const flag of ['branch', 'chat']) {
|
|
515
|
+
if (args[flag]) throw new Error(`--${flag} is only valid with --create`);
|
|
516
|
+
}
|
|
517
|
+
if (args.repos.length > 0) throw new Error('--repo is only valid with --create');
|
|
518
|
+
if (args.bodyFiles.size > 0) throw new Error('--body-file is only valid with --create');
|
|
519
|
+
if (args.forceWithLease) throw new Error('--force-with-lease is only valid with --create');
|
|
520
|
+
if (args.out) throw new Error('--out is only valid with --create');
|
|
521
|
+
}
|
|
522
|
+
return args;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Run one task-pr command. `argv` is a process.argv-shaped array; `deps`
|
|
527
|
+
* is injectable so tests can fake git, the forge, and the tracker:
|
|
528
|
+
* { gitFn, forgeFactory, trackerFactory }
|
|
529
|
+
* Resolves with the command's JSON result; throws on any failure.
|
|
530
|
+
*/
|
|
531
|
+
async function run(argv, deps = {}) {
|
|
532
|
+
const resolved = {
|
|
533
|
+
gitFn: spawnSync,
|
|
534
|
+
forgeFactory: createForge,
|
|
535
|
+
trackerFactory: createTracker,
|
|
536
|
+
...deps,
|
|
537
|
+
};
|
|
538
|
+
const args = parseArgs(argv);
|
|
539
|
+
return args.mode === 'create' ? createPrs(args, resolved) : mergePrs(args, resolved);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function main() {
|
|
543
|
+
const out = await run(process.argv);
|
|
544
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (isMainModule(import.meta.url)) {
|
|
548
|
+
main().catch((err) => {
|
|
549
|
+
process.stderr.write(`task-pr: ${err.message}\n`);
|
|
550
|
+
process.exit(2);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export { run, parseArgs };
|
|
555
|
+
export { parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
@@ -36,6 +36,7 @@ import { join, resolve, relative, sep, isAbsolute, basename, dirname } from 'nod
|
|
|
36
36
|
import { spawnSync } from 'node:child_process';
|
|
37
37
|
import { fileURLToPath } from 'node:url';
|
|
38
38
|
import { readRecord } from './chat-record.mjs';
|
|
39
|
+
import { WORKSPACE_REPO, repoDirFor, mergeModeFor } from './merge-mode.mjs';
|
|
39
40
|
|
|
40
41
|
function isMainModule(metaUrl) {
|
|
41
42
|
if (!process.argv[1]) return false;
|
|
@@ -45,9 +46,6 @@ function isMainModule(metaUrl) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const WORKTREES_DIR = join('.claude', 'worktrees');
|
|
48
|
-
// The workspace repo (the launcher) is addressed as "." — the one repo
|
|
49
|
-
// name that is not a directory under repos/.
|
|
50
|
-
const WORKSPACE_REPO = '.';
|
|
51
49
|
// Exclude patterns are git syntax: forward slashes on every platform.
|
|
52
50
|
const EXCLUDE_LINE = '.claude/worktrees/';
|
|
53
51
|
|
|
@@ -65,12 +63,22 @@ function readConfig(root) {
|
|
|
65
63
|
|
|
66
64
|
// The workspace repo has no workspace.json entry naming its default branch
|
|
67
65
|
// — origin's HEAD is the authority, with "main" as the fallback a fresh
|
|
68
|
-
// clone would get.
|
|
66
|
+
// clone would get. A launcher with no origin never gets an origin/HEAD at
|
|
67
|
+
// all, and no push will ever name its default branch either, so it keeps
|
|
68
|
+
// the branch it sits on when that is main/master rather than being told it
|
|
69
|
+
// is on the wrong branch. Project repos keep their configured branch.
|
|
69
70
|
function defaultBranchFor(root, repo, gitFn = spawnSync) {
|
|
70
71
|
if (isWorkspaceRepo(repo)) {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
72
|
+
const rootDir = resolve(root);
|
|
73
|
+
const head = gitFn('git', ['-C', rootDir, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { encoding: 'utf8' });
|
|
74
|
+
const name = head && head.status === 0 ? String(head.stdout).trim().replace(/^origin\//, '') : '';
|
|
75
|
+
if (name) return name;
|
|
76
|
+
if (run(gitFn, rootDir, ['remote', 'get-url', 'origin']).status !== 0) {
|
|
77
|
+
const on = gitFn('git', ['-C', rootDir, 'branch', '--show-current'], { encoding: 'utf8' });
|
|
78
|
+
const current = on && on.status === 0 ? String(on.stdout).trim() : '';
|
|
79
|
+
if (current === 'main' || current === 'master') return current;
|
|
80
|
+
}
|
|
81
|
+
return 'main';
|
|
74
82
|
}
|
|
75
83
|
const branch = readConfig(root)?.repos?.[repo]?.branch;
|
|
76
84
|
return typeof branch === 'string' && branch ? branch : 'main';
|
|
@@ -111,12 +119,6 @@ function taskWorktreePath(root, repo, branch) {
|
|
|
111
119
|
return join(repoDirFor(resolve(root), repo), WORKTREES_DIR, slugForBranch(branch));
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
// "." is the workspace repo itself — the git repo at the root. Every other
|
|
115
|
-
// name is a directory under repos/.
|
|
116
|
-
function repoDirFor(rootDir, repo) {
|
|
117
|
-
return isWorkspaceRepo(repo) ? rootDir : join(rootDir, 'repos', repo);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
122
|
// .native resolves Windows 8.3 short names; the plain fallback covers
|
|
121
123
|
// filesystems where the native binding is unavailable.
|
|
122
124
|
function realPath(p) {
|
|
@@ -233,10 +235,17 @@ function createTaskWorktree(root, { repo, branch, base = null, gitFn = spawnSync
|
|
|
233
235
|
gitFn('git', ['-C', repoDir, 'fetch', 'origin'], { encoding: 'utf8', timeout: 10000 });
|
|
234
236
|
run(gitFn, repoDir, ['worktree', 'prune']);
|
|
235
237
|
|
|
236
|
-
// The base is a starting point, not a freshness guarantee:
|
|
237
|
-
//
|
|
238
|
+
// The base is a starting point, not a freshness guarantee: origin's
|
|
239
|
+
// default branch as of the best-effort fetch above — unless the repo
|
|
240
|
+
// completes in local mode (gh:173: no origin, or merge: "local"), in
|
|
241
|
+
// which case the local default branch is the base. Local merges land
|
|
242
|
+
// there and never advance the origin ref, so preferring origin's would
|
|
243
|
+
// hand every task after the first local merge a stale, unmergeable base.
|
|
238
244
|
const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
|
|
239
245
|
const resolvedBase = base
|
|
246
|
+
|| (mergeModeFor(rootDir, repo, { gitFn }) === 'local'
|
|
247
|
+
? defaultBranch
|
|
248
|
+
: null)
|
|
240
249
|
|| (refExists(gitFn, repoDir, `refs/remotes/origin/${defaultBranch}`)
|
|
241
250
|
? `origin/${defaultBranch}`
|
|
242
251
|
: defaultBranch);
|
|
@@ -520,6 +529,7 @@ if (isMainModule(import.meta.url)) {
|
|
|
520
529
|
|
|
521
530
|
export {
|
|
522
531
|
slugForBranch, taskWorktreePath, createTaskWorktree, removeTaskWorktree,
|
|
523
|
-
detectWorkModel, parseArgs, defaultBranchFor,
|
|
532
|
+
detectWorkModel, parseArgs, defaultBranchFor,
|
|
524
533
|
WORKTREES_DIR, EXCLUDE_LINE,
|
|
525
534
|
};
|
|
535
|
+
export { WORKSPACE_REPO, repoDirFor } from './merge-mode.mjs';
|
|
@@ -107,6 +107,16 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
107
107
|
gh(['issue', 'comment', String(num), '--repo', repo, '--body-file', '-'], { input: body });
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// A closing reference for PR bodies: `#N` closes an issue in the PR's own
|
|
111
|
+
// repo, `owner/repo#N` reaches one living elsewhere. GitHub only honors a
|
|
112
|
+
// bare `#N` when the PR and the issue share a repo, so callers pass the
|
|
113
|
+
// PR's repo as fromRepo and this decides which shape closes the right
|
|
114
|
+
// issue. No `fromRepo` means the PR is in this adapter's own repo.
|
|
115
|
+
function issueRef(issueId, { fromRepo } = {}) {
|
|
116
|
+
const num = parseIssueNumber(issueId);
|
|
117
|
+
return fromRepo && fromRepo !== repo ? `${repo}#${num}` : `#${num}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
async function closeIssue(issueId, { comment: commentBody } = {}) {
|
|
111
121
|
const num = parseIssueNumber(issueId);
|
|
112
122
|
if (commentBody) {
|
|
@@ -142,6 +152,7 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
142
152
|
createIssue,
|
|
143
153
|
comment,
|
|
144
154
|
closeIssue,
|
|
155
|
+
issueRef,
|
|
145
156
|
ensureLabels,
|
|
146
157
|
ensureMilestone,
|
|
147
158
|
get identity() { return `github-issues:${repo}`; },
|