@retasc/cli 1.42.1 → 1.44.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/CHANGELOG.md +58 -0
- package/dist/commands/claim.js +37 -8
- package/dist/commands/tidy.js +122 -32
- package/dist/index.js +5 -2
- package/dist/lib/base.js +222 -0
- package/dist/lib/format.js +4 -2
- package/dist/lib/session.js +7 -1
- package/dist/lib/tidy.js +7 -3
- package/dist/proxy.js +19 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,64 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.44.0 (2026-09-08)
|
|
10
|
+
|
|
11
|
+
- **RTSC-861** — the proxy now tells Retasc which CLI version it is. It rides on
|
|
12
|
+
`mint_session_key`, the one call every proxy already makes at startup, as an optional
|
|
13
|
+
`cliVersion` argument, so an older build that sends nothing still mints exactly as
|
|
14
|
+
before.
|
|
15
|
+
|
|
16
|
+
Why it matters: the update notice (RTSC-520) fires from a `postAction` hook on
|
|
17
|
+
`retasc <command>`, and someone whose only contact with the CLI is the proxy their
|
|
18
|
+
harness spawns runs no commands and never sees it. Three live setups were found on
|
|
19
|
+
builds older than 1.41.0 with no way to find out. The server can now say so in the
|
|
20
|
+
tool result, which reaches every harness, and with this version it can name the exact
|
|
21
|
+
gap instead of inferring one from behaviour.
|
|
22
|
+
|
|
23
|
+
## 1.43.0 (2026-09-05)
|
|
24
|
+
|
|
25
|
+
- **RTSC-799** - `tidy`, `done` and `claim` now work out which branch is this repo's trunk
|
|
26
|
+
instead of assuming it is called `main`. Until this release `tidy` compared every branch
|
|
27
|
+
against `origin/main`, a name written into the source with no flag, no config and no way
|
|
28
|
+
to see it. In a repo whose trunk is `master` that comparison does not fail into view:
|
|
29
|
+
`git merge-base --is-ancestor` exits 128 for an unreadable ref, the code tested only for
|
|
30
|
+
0, and so a broken lookup and an honest "not merged" produced the same word. Every branch
|
|
31
|
+
read `merged: no`, nothing was ever reapable, and because `claim` creates a worktree per
|
|
32
|
+
issue and only `tidy` removes one, the worktrees accumulated with nothing printed to say
|
|
33
|
+
why. The team who reported it had thirty of them, built up over months, and could not tell
|
|
34
|
+
from the outside whether the tool was wrong or their setup was.
|
|
35
|
+
The trunk now comes from `origin/HEAD`, which is what `git clone` already records and what
|
|
36
|
+
`git fetch` re-records when it goes stale. Override it per repo with
|
|
37
|
+
`git config --local retasc.base origin/develop`, or per run with a new `--base` on `tidy`
|
|
38
|
+
and `done`. Read at `--local` scope on purpose: a plain `git config --get` also sees
|
|
39
|
+
`~/.gitconfig`, and one machine-wide value would have quietly redefined the trunk in every
|
|
40
|
+
repo on the box and outranked each repo's own `origin/HEAD`. When the trunk cannot be
|
|
41
|
+
worked out at all, that is an error naming the ref and every way to set one, because a
|
|
42
|
+
command that cannot tell what merged means must not go on to report that nothing did.
|
|
43
|
+
Ancestry now has three answers rather than two. "Git could not tell" is carried through as
|
|
44
|
+
its own state, prints as `?`, and is never deleted, not even under `--force`: that flag
|
|
45
|
+
means "unmerged, but I know the work landed elsewhere", which is a judgement about a branch
|
|
46
|
+
git actually reported on. Every run also prints the trunk it measured against, which is the
|
|
47
|
+
line that would have turned this into a one-minute diagnosis, and a shallow clone warns,
|
|
48
|
+
since missing history produces the same uniform "no".
|
|
49
|
+
Care taken with the sharp edges, because this turns a command that deleted nothing in those
|
|
50
|
+
repos into one that deletes:
|
|
51
|
+
- A stale `origin/HEAD`, which outlives the branch it names when a remote renames its trunk
|
|
52
|
+
and which git before 2.45 never repairs, falls through to the next candidate with a note
|
|
53
|
+
rather than refusing `claim`, `tidy` and `done` in a repo that is otherwise healthy.
|
|
54
|
+
- `done` still closes the issue when the trunk cannot be resolved. The control-plane close
|
|
55
|
+
is the durable half and a CI checkout legitimately has no `origin/HEAD`; the teardown is
|
|
56
|
+
what needs a trunk, so that is what reports.
|
|
57
|
+
- The first `--prune` after upgrading asks once before deleting five or more branches, since
|
|
58
|
+
it may be clearing months of work a team believed this command never touched. Scripts and
|
|
59
|
+
the MCP auto-reap never see the question, and `--yes` skips it.
|
|
60
|
+
- `claim` fetches before it proves the base exists, so a just-pushed base still works, and
|
|
61
|
+
resolves before taking the lease, so a claim is never held for a worktree that cannot be
|
|
62
|
+
created. It also prints the trunk it branched from.
|
|
63
|
+
- A bare repository is refused rather than operated on (the old path resolved to its parent
|
|
64
|
+
directory), a detached HEAD no longer defeats the "that's your current branch" guard, and
|
|
65
|
+
the watchdog now logs a non-zero exit from an auto-reap instead of swallowing it.
|
|
66
|
+
|
|
9
67
|
## 1.42.1 (2026-09-05)
|
|
10
68
|
|
|
11
69
|
- **RTSC-832** — the model now actually appears. 1.42.0 read it from the SessionStart
|
package/dist/commands/claim.js
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname, resolve, sep } from "node:path";
|
|
|
4
4
|
import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, resolveIssueRef, } from "../lib/claim.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { clean } from "../lib/text.js";
|
|
7
|
+
import { resolveBase, baseHelp } from "../lib/base.js";
|
|
7
8
|
function git(args, cwd) {
|
|
8
9
|
return spawnSync("git", args, { encoding: "utf8", cwd });
|
|
9
10
|
}
|
|
@@ -72,6 +73,7 @@ export async function claimAction(opts) {
|
|
|
72
73
|
let parentDir = opts.dir;
|
|
73
74
|
let repoName = "repo";
|
|
74
75
|
let inLinkedWorktree = false;
|
|
76
|
+
let base = "";
|
|
75
77
|
if (makeWorktree) {
|
|
76
78
|
const commonDir = gitOut(["rev-parse", "--git-common-dir"]);
|
|
77
79
|
const gitDir = gitOut(["rev-parse", "--git-dir"]);
|
|
@@ -86,6 +88,38 @@ export async function claimAction(opts) {
|
|
|
86
88
|
const mainCheckout = dirname(resolve(commonDir)); // <main>/.git → <main>
|
|
87
89
|
repoName = basename(mainCheckout);
|
|
88
90
|
parentDir = parentDir ?? dirname(mainCheckout);
|
|
91
|
+
// RTSC-799: the branch base is discovered from the repo, not assumed to be
|
|
92
|
+
// `origin/main`. Resolved HERE, before the claim: a lease taken for a worktree
|
|
93
|
+
// that then can't be created leaves the issue held by an agent with nowhere to
|
|
94
|
+
// work, and the caller has to hand it back to free it.
|
|
95
|
+
//
|
|
96
|
+
// The fetch comes FIRST, and has to. Resolving proves the base resolves to a
|
|
97
|
+
// commit, and a base that is only on the remote so far (a just-pushed branch, a
|
|
98
|
+
// --single-branch clone) does not resolve until it has been fetched. Proving
|
|
99
|
+
// before fetching would refuse claims that worked before this change.
|
|
100
|
+
if (opts.fetch !== false) {
|
|
101
|
+
const named = opts.base?.startsWith("origin/") ? opts.base.slice("origin/".length) : null;
|
|
102
|
+
const f = named ? git(["fetch", "origin", named]) : git(["fetch", "origin"]);
|
|
103
|
+
if (f.status !== 0)
|
|
104
|
+
note(`⚠ git fetch failed (${(f.stderr || "").trim().split("\n")[0]}); using local refs.`);
|
|
105
|
+
}
|
|
106
|
+
// Same cwd as `tidy` uses (the main checkout), so `claim` and the `tidy` that
|
|
107
|
+
// later judges the branch can never read a different trunk for the same repo.
|
|
108
|
+
const gitRun = (args) => {
|
|
109
|
+
const r = git(args, mainCheckout);
|
|
110
|
+
return { status: r.status, stdout: r.stdout ?? "" };
|
|
111
|
+
};
|
|
112
|
+
const resolved = resolveBase(gitRun, opts.base);
|
|
113
|
+
if (!resolved.ok) {
|
|
114
|
+
for (const line of baseHelp(resolved))
|
|
115
|
+
note(line);
|
|
116
|
+
note(" Nothing was claimed. Or pass --no-worktree to claim without one.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
if (resolved.staleOriginHead) {
|
|
120
|
+
note(`⚠ origin/HEAD still points at ${resolved.staleOriginHead}, which is gone. Branching from ${resolved.ref}.`);
|
|
121
|
+
}
|
|
122
|
+
base = resolved.ref;
|
|
89
123
|
}
|
|
90
124
|
// --- claim over MCP ------------------------------------------------------
|
|
91
125
|
let claim;
|
|
@@ -170,14 +204,6 @@ export async function claimAction(opts) {
|
|
|
170
204
|
finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
|
|
171
205
|
return;
|
|
172
206
|
}
|
|
173
|
-
// Best-effort refresh of the base so the worktree starts from current origin.
|
|
174
|
-
const base = opts.base ?? "origin/main";
|
|
175
|
-
if (opts.fetch !== false) {
|
|
176
|
-
const remoteBranch = base.startsWith("origin/") ? base.slice("origin/".length) : null;
|
|
177
|
-
const f = remoteBranch ? git(["fetch", "origin", remoteBranch]) : git(["fetch", "origin"]);
|
|
178
|
-
if (f.status !== 0)
|
|
179
|
-
note(`⚠ git fetch failed (${(f.stderr || "").trim().split("\n")[0]}); using local ${base}.`);
|
|
180
|
-
}
|
|
181
207
|
const add = git(["worktree", "add", plan.path, "-b", plan.branch, base]);
|
|
182
208
|
if (add.status !== 0) {
|
|
183
209
|
const err = (add.stderr || add.stdout || "").trim().split("\n")[0];
|
|
@@ -199,6 +225,9 @@ export async function claimAction(opts) {
|
|
|
199
225
|
note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
|
|
200
226
|
note(` worktree: ${plan.path}`);
|
|
201
227
|
note(` branch: ${plan.branch}`);
|
|
228
|
+
// Name the trunk here too: `tidy` prints it, and claim is the other command that
|
|
229
|
+
// acts on it. An unexpected trunk should be visible before the work starts.
|
|
230
|
+
note(` from: ${base}`);
|
|
202
231
|
// RTSC-446: a fresh worktree checks out tracked files only — deps/artifacts are absent.
|
|
203
232
|
note(` Fresh worktree: only tracked files. Run the repo's usual setup (what docs/CI`);
|
|
204
233
|
note(` run on a fresh checkout) before your first build or test.`);
|
package/dist/commands/tidy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveMcpConn, readMcpJson, mcpCall, isValidIssueId } from "../lib/cla
|
|
|
4
4
|
import { issueIdFromBranch, classifyBranch, parseWorktreePorcelain, } from "../lib/tidy.js";
|
|
5
5
|
import { confirm, isInteractive } from "../lib/prompt.js";
|
|
6
6
|
import { doneReadback } from "../lib/format.js";
|
|
7
|
+
import { resolveBase, baseHelp, mergedInto, isShallow } from "../lib/base.js";
|
|
7
8
|
// `retasc tidy` / `retasc done` (RTSC-93). The CLI already creates the worktree +
|
|
8
9
|
// branch on claim (commands/claim.ts); this closes the loop — it tears them down
|
|
9
10
|
// when the issue is done, reading status read-only from the control plane and
|
|
@@ -20,9 +21,11 @@ function gitOut(args, cwd) {
|
|
|
20
21
|
function note(msg) {
|
|
21
22
|
process.stderr.write(msg + "\n");
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
/** Resolve the MCP connection, the main checkout, and the trunk — or exit with a
|
|
25
|
+
* clear message. Every exit here happens BEFORE `done` writes a terminal status:
|
|
26
|
+
* closing an issue and only then discovering the teardown can't run is the worse
|
|
27
|
+
* of the two failures. */
|
|
28
|
+
function setup(baseOpt) {
|
|
26
29
|
const conn = resolveMcpConn({ mcpJson: readMcpJson() });
|
|
27
30
|
if (!conn.key) {
|
|
28
31
|
note("✗ No Retasc MCP key found. Run `retasc mcp install`, or set RETASC_MCP_KEY.");
|
|
@@ -33,11 +36,49 @@ function setup() {
|
|
|
33
36
|
note("✗ Not inside a git repository.");
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
39
|
+
// In a BARE repo --git-common-dir is `.`, so this would resolve to the bare repo's
|
|
40
|
+
// PARENT — and every branch delete, worktree remove and `push --delete` below would
|
|
41
|
+
// then run against whatever repository happens to live there. Refuse instead.
|
|
42
|
+
if (gitOut(["rev-parse", "--is-bare-repository"]) === "true") {
|
|
43
|
+
note("✗ This is a bare repository — there are no worktrees or checkouts here to tidy.");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
36
46
|
// --git-common-dir always points at the MAIN checkout's .git, even from a linked
|
|
37
47
|
// worktree — so worktree/branch ops always run against the main checkout.
|
|
38
48
|
const mainCheckout = dirname(resolve(commonDir));
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
// symbolic-ref, not `rev-parse --abbrev-ref`: the latter returns the literal string
|
|
50
|
+
// "HEAD" when detached, which would then match no branch and silently disarm the
|
|
51
|
+
// "that's your current branch" guard below.
|
|
52
|
+
const currentBranch = gitOut(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
53
|
+
// RTSC-799: the trunk is discovered, not assumed. An unresolvable one is a hard
|
|
54
|
+
// stop — the alternative it replaces was reporting every branch as unmerged and
|
|
55
|
+
// reaping nothing, for months, with nothing printed to say why.
|
|
56
|
+
const run = (args) => {
|
|
57
|
+
const r = git(args, mainCheckout);
|
|
58
|
+
return { status: r.status, stdout: r.stdout ?? "" };
|
|
59
|
+
};
|
|
60
|
+
// Refresh BEFORE resolving. The prune is what deletes `origin/master` after a remote
|
|
61
|
+
// rename, so resolving first would bless a ref this very command is about to remove,
|
|
62
|
+
// and every row would then read "?" against a trunk that no longer exists.
|
|
63
|
+
git(["fetch", "origin", "--prune"], mainCheckout);
|
|
64
|
+
const base = resolveBase(run, baseOpt);
|
|
65
|
+
if (!base.ok) {
|
|
66
|
+
return { conn, mainCheckout, currentBranch, run, base: null, baseFailure: base };
|
|
67
|
+
}
|
|
68
|
+
if (base.staleOriginHead) {
|
|
69
|
+
note(`⚠ origin/HEAD still points at ${base.staleOriginHead}, which is gone. Using ${base.ref}.`);
|
|
70
|
+
note(` \`git remote set-head origin -a\` re-records it.`);
|
|
71
|
+
}
|
|
72
|
+
// A shallow clone can be missing the very commits ancestry is read from, which
|
|
73
|
+
// produces the same uniform "not merged" this command exists to stop believing.
|
|
74
|
+
// `null` (git couldn't say) warns too: a suppressed warning is the same silence.
|
|
75
|
+
const shallow = isShallow(run);
|
|
76
|
+
if (shallow !== false) {
|
|
77
|
+
note(shallow === true
|
|
78
|
+
? `⚠ Shallow clone — merge state against ${base.ref} may be wrong. \`git fetch --unshallow\` for a true answer.`
|
|
79
|
+
: `⚠ Couldn't tell whether this is a shallow clone; merge state against ${base.ref} may be wrong.`);
|
|
80
|
+
}
|
|
81
|
+
return { conn, mainCheckout, currentBranch, run, base: base.ref, baseFailure: null };
|
|
41
82
|
}
|
|
42
83
|
/** Short ref names under a refspec (`origin/` stripped when asked). */
|
|
43
84
|
function refList(mainCheckout, refspec, strip) {
|
|
@@ -51,8 +92,6 @@ function refList(mainCheckout, refspec, strip) {
|
|
|
51
92
|
* joined with its issue status (read-only) and merged-into-main state. */
|
|
52
93
|
async function scan(ctx, only) {
|
|
53
94
|
const { conn, mainCheckout } = ctx;
|
|
54
|
-
// Refresh so merged-state + the remote list are current. Best-effort.
|
|
55
|
-
git(["fetch", "origin", "--prune"], mainCheckout);
|
|
56
95
|
const worktrees = parseWorktreePorcelain(gitOut(["worktree", "list", "--porcelain"], mainCheckout) ?? "");
|
|
57
96
|
const wtByBranch = new Map();
|
|
58
97
|
for (const w of worktrees)
|
|
@@ -87,7 +126,11 @@ async function scan(ctx, only) {
|
|
|
87
126
|
const remote = remoteSet.has(branch);
|
|
88
127
|
// Merged check prefers the remote ref (authoritative); falls back to the local.
|
|
89
128
|
const ref = remote ? `origin/${branch}` : branch;
|
|
90
|
-
const merged =
|
|
129
|
+
const merged = mergedInto(ctx.run, ref, ctx.base);
|
|
130
|
+
// The base already resolved, so an unknown here is the ref itself — say so
|
|
131
|
+
// rather than letting a "no" in the table stand in for a failed check.
|
|
132
|
+
if (merged === null)
|
|
133
|
+
note(`⚠ ${branch}: git couldn't compare ${ref} against ${ctx.base} — treating it as unmerged.`);
|
|
91
134
|
return {
|
|
92
135
|
branch,
|
|
93
136
|
issue,
|
|
@@ -100,6 +143,8 @@ async function scan(ctx, only) {
|
|
|
100
143
|
};
|
|
101
144
|
});
|
|
102
145
|
}
|
|
146
|
+
/** Ask once before a prune this large. See the call site for why. */
|
|
147
|
+
const BULK_CONFIRM_AT = 5;
|
|
103
148
|
const VERDICT_LABEL = {
|
|
104
149
|
reap: "reap",
|
|
105
150
|
orphan: "orphan — review",
|
|
@@ -111,7 +156,7 @@ function printTable(rows) {
|
|
|
111
156
|
note(` ${"branch".padEnd(w)} issue status merged verdict`);
|
|
112
157
|
for (const r of rows) {
|
|
113
158
|
note(` ${r.branch.padEnd(w)} ${(r.issue ?? "—").padEnd(9)} ${(r.status ?? "—").padEnd(9)} ` +
|
|
114
|
-
`${(r.merged ? "yes" : "no").padEnd(6)} ${VERDICT_LABEL[r.verdict]}`);
|
|
159
|
+
`${(r.merged === null ? "?" : r.merged ? "yes" : "no").padEnd(6)} ${VERDICT_LABEL[r.verdict]}`);
|
|
115
160
|
}
|
|
116
161
|
}
|
|
117
162
|
/** Delete one branch's artifacts (worktree → local branch → remote branch),
|
|
@@ -123,13 +168,16 @@ function reap(row, ctx, force) {
|
|
|
123
168
|
return "skipped";
|
|
124
169
|
}
|
|
125
170
|
// `row.merged` was computed from the REMOTE ref, but we force-delete the LOCAL
|
|
126
|
-
// branch below. If the local branch carries commits not yet on
|
|
171
|
+
// branch below. If the local branch carries commits not yet on the trunk
|
|
127
172
|
// (committed-but-unpushed), skip unless --force — so `branch -D` can't silently
|
|
128
|
-
// discard them. (--force is the consented escape hatch.)
|
|
173
|
+
// discard them. (--force is the consented escape hatch.) An unknown answer skips
|
|
174
|
+
// for the same reason a "no" does: only a proven-landed branch gets deleted.
|
|
129
175
|
if (row.local && !force) {
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
132
|
-
note(
|
|
176
|
+
const localOnBase = mergedInto(ctx.run, row.branch, ctx.base);
|
|
177
|
+
if (localOnBase !== true) {
|
|
178
|
+
note(localOnBase === null
|
|
179
|
+
? ` · skip ${row.branch} — couldn't check its local branch against ${ctx.base}`
|
|
180
|
+
: ` · skip ${row.branch} — local branch has commits not on ${ctx.base} (unpushed?); --force to delete`);
|
|
133
181
|
return "skipped";
|
|
134
182
|
}
|
|
135
183
|
}
|
|
@@ -157,7 +205,7 @@ function reap(row, ctx, force) {
|
|
|
157
205
|
return "failed";
|
|
158
206
|
}
|
|
159
207
|
}
|
|
160
|
-
// -D is safe here: either the local branch is on
|
|
208
|
+
// -D is safe here: either the local branch is on the trunk (verified above) or
|
|
161
209
|
// the user passed --force. `--` guards against any future caller bypassing the
|
|
162
210
|
// issueIdFromBranch filter (the regex already forbids leading-dash names).
|
|
163
211
|
if (row.local) {
|
|
@@ -184,12 +232,23 @@ function reap(row, ctx, force) {
|
|
|
184
232
|
* --force; untracked branches (no matching issue) are never touched.
|
|
185
233
|
*/
|
|
186
234
|
export async function tidyAction(opts) {
|
|
187
|
-
const ctx = setup();
|
|
188
|
-
|
|
235
|
+
const ctx = setup(opts.base);
|
|
236
|
+
// `tidy`'s entire job is merge state, so it has nothing to say without a trunk.
|
|
237
|
+
// (`done` treats the same failure as non-fatal: its write is the durable half.)
|
|
238
|
+
if (ctx.base === null) {
|
|
239
|
+
for (const line of baseHelp(ctx.baseFailure))
|
|
240
|
+
note(line);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const resolved = ctx;
|
|
244
|
+
const rows = await scan(resolved, opts.only);
|
|
189
245
|
if (rows.length === 0) {
|
|
190
246
|
note(opts.only ? `· No branch found for ${opts.only}.` : "· No issue branches (rtsc-NN/…) found.");
|
|
191
247
|
return;
|
|
192
248
|
}
|
|
249
|
+
// Name the trunk every run. Without it "merged: no" is unfalsifiable from the
|
|
250
|
+
// outside, which is precisely how RTSC-799 survived for months.
|
|
251
|
+
note(`· merged is measured against ${resolved.base}`);
|
|
193
252
|
if (opts.json) {
|
|
194
253
|
console.log(JSON.stringify(rows, null, 2));
|
|
195
254
|
}
|
|
@@ -198,7 +257,13 @@ export async function tidyAction(opts) {
|
|
|
198
257
|
}
|
|
199
258
|
const reapable = rows.filter((r) => r.verdict === "reap");
|
|
200
259
|
const orphans = rows.filter((r) => r.verdict === "orphan");
|
|
201
|
-
|
|
260
|
+
// --force means "unmerged, but I know the work landed elsewhere" — a judgement about
|
|
261
|
+
// a branch whose state git DID report. A row git couldn't judge at all is not that,
|
|
262
|
+
// and deleting it would make this file's own rule ("an unknown can never authorize a
|
|
263
|
+
// delete") false. Those need the ref fixed, not a bigger hammer.
|
|
264
|
+
const unknown = orphans.filter((r) => r.merged === null);
|
|
265
|
+
const forceable = orphans.filter((r) => r.merged !== null);
|
|
266
|
+
const targets = [...reapable, ...(opts.force ? forceable : [])];
|
|
202
267
|
if (!opts.prune) {
|
|
203
268
|
note("");
|
|
204
269
|
const n = reapable.length;
|
|
@@ -211,14 +276,30 @@ export async function tidyAction(opts) {
|
|
|
211
276
|
}
|
|
212
277
|
if (targets.length === 0) {
|
|
213
278
|
note("");
|
|
214
|
-
note(
|
|
279
|
+
note(forceable.length ? "→ only orphans remain; re-run with --force to delete them." : "→ nothing to reap.");
|
|
280
|
+
if (unknown.length)
|
|
281
|
+
note(`→ ${unknown.length} branch(es) git couldn't judge are left alone, even with --force.`);
|
|
215
282
|
return;
|
|
216
283
|
}
|
|
284
|
+
// The first --prune after upgrading can be enormous: this command was a silent no-op
|
|
285
|
+
// in every repo whose trunk wasn't `main`, so a team can be sitting on months of
|
|
286
|
+
// branches it never reaped. Deleting all of them on one keystroke, against a mental
|
|
287
|
+
// model that says --prune does nothing, is not a fix. Scripts never see this (no TTY),
|
|
288
|
+
// and the proxy's auto-reap passes --only, so it is always a single branch.
|
|
289
|
+
if (!opts.only && !opts.yes && isInteractive() && targets.length >= BULK_CONFIRM_AT) {
|
|
290
|
+
note("");
|
|
291
|
+
const remotes = targets.filter((t) => t.remote).length;
|
|
292
|
+
note(`About to delete ${targets.length} branches (${remotes} also on origin) and their worktrees.`);
|
|
293
|
+
if (!(await confirm("Delete them?"))) {
|
|
294
|
+
note("· Nothing deleted.");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
217
298
|
note("");
|
|
218
299
|
let deleted = 0;
|
|
219
300
|
let failed = 0;
|
|
220
301
|
for (const row of targets) {
|
|
221
|
-
const r = reap(row,
|
|
302
|
+
const r = reap(row, resolved, !!opts.force);
|
|
222
303
|
if (r === "deleted")
|
|
223
304
|
deleted++;
|
|
224
305
|
else if (r === "failed")
|
|
@@ -229,10 +310,11 @@ export async function tidyAction(opts) {
|
|
|
229
310
|
process.exitCode = 1; // let scripts/CI see a partial failure
|
|
230
311
|
}
|
|
231
312
|
/**
|
|
232
|
-
* Is every branch for this issue already on
|
|
313
|
+
* Is every branch for this issue already on the trunk? null when it has no branch here.
|
|
233
314
|
*
|
|
234
|
-
* Pessimistic on purpose: if ANY branch for the issue is unmerged
|
|
235
|
-
* readback exists to stop a close over open work, so the cautious
|
|
315
|
+
* Pessimistic on purpose: if ANY branch for the issue is unmerged — or can't be checked —
|
|
316
|
+
* this says unmerged. The readback exists to stop a close over open work, so the cautious
|
|
317
|
+
* answer is the true one.
|
|
236
318
|
*/
|
|
237
319
|
function issueBranchMerged(ctx, issueId) {
|
|
238
320
|
const locals = refList(ctx.mainCheckout, "refs/heads/");
|
|
@@ -241,8 +323,7 @@ function issueBranchMerged(ctx, issueId) {
|
|
|
241
323
|
const branches = [...new Set([...locals, ...remotes])].filter((b) => issueIdFromBranch(b) === issueId);
|
|
242
324
|
if (branches.length === 0)
|
|
243
325
|
return null;
|
|
244
|
-
return branches.every((b) =>
|
|
245
|
-
.status === 0);
|
|
326
|
+
return branches.every((b) => mergedInto(ctx.run, remoteSet.has(b) ? `origin/${b}` : b, ctx.base) === true);
|
|
246
327
|
}
|
|
247
328
|
/**
|
|
248
329
|
* `retasc done` — the symmetric close to `retasc claim`: mark the current issue
|
|
@@ -265,7 +346,7 @@ export async function doneAction(opts) {
|
|
|
265
346
|
// setup() resolves the MCP key AND the main checkout. Both are needed either way, and
|
|
266
347
|
// failing on a missing repo BEFORE the write beats closing the issue and then finding
|
|
267
348
|
// the teardown can't run.
|
|
268
|
-
const ctx = setup();
|
|
349
|
+
const ctx = setup(opts.base);
|
|
269
350
|
// Normalize an explicit --id to the canonical uppercase form so `done --id rtsc-93`
|
|
270
351
|
// matches the uppercased branch ids (and is sent canonically to save_issue).
|
|
271
352
|
const issueId = opts.id
|
|
@@ -277,9 +358,8 @@ export async function doneAction(opts) {
|
|
|
277
358
|
note("✗ Not on a rtsc-NN/ branch — pass --id <RTSC-NN>.");
|
|
278
359
|
process.exit(1);
|
|
279
360
|
}
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
git(["fetch", "origin", "--prune"], ctx.mainCheckout);
|
|
361
|
+
// (setup() already fetched: a stale trunk reports merged work as unmerged, which is
|
|
362
|
+
// the warning that teaches people to ignore the warning.)
|
|
283
363
|
let issue = null;
|
|
284
364
|
try {
|
|
285
365
|
issue = await mcpCall(ctx.conn, "get_issue", { identifier: issueId });
|
|
@@ -289,12 +369,22 @@ export async function doneAction(opts) {
|
|
|
289
369
|
// below carry the decision with what little is known.
|
|
290
370
|
note(`⚠ Couldn't read ${issueId}: ${String(e?.message ?? e).split("\n")[0]}`);
|
|
291
371
|
}
|
|
292
|
-
|
|
372
|
+
// An unresolvable trunk is NOT fatal here, deliberately. The control-plane close is
|
|
373
|
+
// the durable half of `done`; the git teardown is local best-effort. Refusing to close
|
|
374
|
+
// an issue because a checkout has no origin/HEAD — which is exactly what a CI clone
|
|
375
|
+
// looks like — would break the scripts this command promises to keep working. So say
|
|
376
|
+
// what couldn't be checked, and let the human decide with that in front of them.
|
|
377
|
+
if (ctx.base === null) {
|
|
378
|
+
note(`⚠ Can't tell whether the branch merged: ${ctx.baseFailure.reason}.`);
|
|
379
|
+
note(` Closing is still fine; the branch teardown below is what needs a trunk.`);
|
|
380
|
+
}
|
|
381
|
+
const resolved = ctx.base === null ? null : ctx;
|
|
382
|
+
note(doneReadback({ ...(issue ?? {}), id: issueId }, resolved ? issueBranchMerged(resolved, issueId) : null, ctx.base ?? "the trunk"));
|
|
293
383
|
if (opts.dryRun) {
|
|
294
384
|
note("");
|
|
295
385
|
note(`· Dry run: ${issueId} was NOT closed. Below is what the teardown would do.`);
|
|
296
386
|
note("");
|
|
297
|
-
await tidyAction({ force: opts.force, only: issueId });
|
|
387
|
+
await tidyAction({ force: opts.force, base: ctx.base ?? undefined, only: issueId });
|
|
298
388
|
return;
|
|
299
389
|
}
|
|
300
390
|
// Already done: go straight to the teardown. Re-sending the write would cost a metered
|
|
@@ -321,5 +411,5 @@ export async function doneAction(opts) {
|
|
|
321
411
|
note(`✓ ${issueId} → done`);
|
|
322
412
|
}
|
|
323
413
|
note(" tidying its branch…");
|
|
324
|
-
await tidyAction({ prune: true, force: opts.force, only: issueId });
|
|
414
|
+
await tidyAction({ prune: true, force: opts.force, base: ctx.base ?? undefined, only: issueId });
|
|
325
415
|
}
|
package/dist/index.js
CHANGED
|
@@ -703,7 +703,7 @@ function addClaimFlags(cmd) {
|
|
|
703
703
|
return cmd
|
|
704
704
|
.option("--id <RTSC-NN>", "Claim one specific issue instead of the next unblocked one")
|
|
705
705
|
.option("--all-lanes", "Pull from every lane, including other humans' work (default: only your lane + unassigned)")
|
|
706
|
-
.option("--base <ref>", "Base ref for the new branch
|
|
706
|
+
.option("--base <ref>", "Base ref for the new branch (default: this repo's trunk, from origin/HEAD)")
|
|
707
707
|
.option("--dir <path>", "Parent dir for the worktree (default: beside the main checkout)")
|
|
708
708
|
.option("--no-fetch", "Skip refreshing the base ref from origin first")
|
|
709
709
|
.option("--no-worktree", "Just claim — don't create a worktree")
|
|
@@ -731,6 +731,8 @@ program
|
|
|
731
731
|
.option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
|
|
732
732
|
.option("--force", "Also delete orphans (issue done but branch unmerged)")
|
|
733
733
|
.option("--json", "Emit the reconciled branch table as JSON")
|
|
734
|
+
.option("--base <ref>", "Trunk to measure merge state against (default: this repo's trunk, from origin/HEAD)")
|
|
735
|
+
.option("-y, --yes", "Skip the confirmation before a large prune")
|
|
734
736
|
.option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
|
|
735
737
|
.action((opts) =>
|
|
736
738
|
// Normalize --only to the canonical uppercase id so it matches the branch ids
|
|
@@ -747,7 +749,8 @@ program
|
|
|
747
749
|
.command("done")
|
|
748
750
|
.description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
|
|
749
751
|
.option("--id <RTSC-NN>", "The issue to close (default: derived from the current branch)")
|
|
750
|
-
.option("--force", "Tear down even if the branch isn't merged into
|
|
752
|
+
.option("--force", "Tear down even if the branch isn't merged into the trunk yet")
|
|
753
|
+
.option("--base <ref>", "Trunk to measure merge state against (default: this repo's trunk, from origin/HEAD)")
|
|
751
754
|
// RTSC-709: a read before the write. `done` is one word that closes an issue, and it
|
|
752
755
|
// closed one whose PR was still open. --dry-run shows the readback and the teardown plan
|
|
753
756
|
// without touching anything; -y is for the human who already knows (scripts never prompt).
|
package/dist/lib/base.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Which ref is this repo's trunk? (RTSC-799)
|
|
2
|
+
//
|
|
3
|
+
// `tidy` and `done` decide whether a branch's work has landed by asking git
|
|
4
|
+
// whether it's an ancestor of the trunk, and `claim` branches from the trunk.
|
|
5
|
+
// All three used to spell that trunk `origin/main`, written into the source with
|
|
6
|
+
// no way to change it. In a repo whose default branch is `master` the comparison
|
|
7
|
+
// doesn't error into view — `merge-base --is-ancestor` exits 128, the caller only
|
|
8
|
+
// tested for 0, and every branch came back "not merged". Nothing was ever
|
|
9
|
+
// reapable, so `claim` kept minting worktrees that nothing ever removed. One
|
|
10
|
+
// customer accumulated thirty of them over months with no error printed once.
|
|
11
|
+
//
|
|
12
|
+
// So two rules live here, and they're the whole point of the file:
|
|
13
|
+
// 1. The trunk is DISCOVERED (origin/HEAD, the same thing `git clone` records),
|
|
14
|
+
// overridable per-repo, and never assumed.
|
|
15
|
+
// 2. A git failure is never an answer. `merge-base --is-ancestor` has three
|
|
16
|
+
// outcomes and this module returns three, so "couldn't tell" can't wear the
|
|
17
|
+
// same face as "no".
|
|
18
|
+
//
|
|
19
|
+
// The decision (`pickBase`) is pure and takes the repo's facts as data, so the
|
|
20
|
+
// precedence order is testable without a git fixture; `resolveBase` is the thin
|
|
21
|
+
// layer that gathers those facts.
|
|
22
|
+
/**
|
|
23
|
+
* Per-repo override: `git config retasc.base origin/develop`.
|
|
24
|
+
*
|
|
25
|
+
* Read at `--local` scope ONLY, and deliberately. A plain `git config --get` also
|
|
26
|
+
* sees ~/.gitconfig, so one global value would redefine the trunk in every repo on
|
|
27
|
+
* the machine and outrank each repo's own origin/HEAD — and the trunk is the only
|
|
28
|
+
* evidence behind `git branch -D` / `git push origin --delete`, which the proxy runs
|
|
29
|
+
* unattended on every terminal close. A machine-wide answer to a per-repo question
|
|
30
|
+
* is not a default, it is a footgun.
|
|
31
|
+
*/
|
|
32
|
+
export const BASE_CONFIG_KEY = "retasc.base";
|
|
33
|
+
/**
|
|
34
|
+
* Choose the trunk from what the repo already knows, most explicit first:
|
|
35
|
+
* `--base` → `git config retasc.base` → `origin/HEAD` → the one of
|
|
36
|
+
* `origin/{main,master}` that exists → for a repo with no remote, the one of
|
|
37
|
+
* `main`/`master` that exists.
|
|
38
|
+
*
|
|
39
|
+
* Ambiguity is a failure, never a guess. A repo carrying BOTH `origin/main` and
|
|
40
|
+
* `origin/master` with no `origin/HEAD` to break the tie gets an error naming the
|
|
41
|
+
* two, because picking the wrong one here doesn't misreport one row — it silently
|
|
42
|
+
* reclassifies every branch in the repo.
|
|
43
|
+
*/
|
|
44
|
+
export function pickBase(i) {
|
|
45
|
+
// Only ABSENT skips. An empty `--base "$UNSET_VAR"` must not quietly become
|
|
46
|
+
// "discover one for me" and then measure deletions against a trunk the caller
|
|
47
|
+
// never named — the sibling rule claim.ts already spells out for issue ids.
|
|
48
|
+
if (i.explicit != null && i.explicit.trim() === "") {
|
|
49
|
+
return { ok: false, reason: "--base was given an empty value" };
|
|
50
|
+
}
|
|
51
|
+
if (i.configured != null && i.configured.trim() === "") {
|
|
52
|
+
return { ok: false, reason: `git config ${BASE_CONFIG_KEY} is set to an empty value` };
|
|
53
|
+
}
|
|
54
|
+
if (i.explicit)
|
|
55
|
+
return { ok: true, ref: i.explicit, source: "flag" };
|
|
56
|
+
if (i.configured)
|
|
57
|
+
return { ok: true, ref: i.configured, source: "config" };
|
|
58
|
+
if (i.originHead)
|
|
59
|
+
return { ok: true, ref: i.originHead, source: "origin-head" };
|
|
60
|
+
if (i.hasOrigin) {
|
|
61
|
+
if (i.remoteCandidates.length === 1) {
|
|
62
|
+
return { ok: true, ref: i.remoteCandidates[0], source: "remote-default" };
|
|
63
|
+
}
|
|
64
|
+
if (i.remoteCandidates.length > 1) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: `origin has both ${i.remoteCandidates.join(" and ")}, and no origin/HEAD to say which is the trunk`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "origin has no recorded default branch (no origin/HEAD), and neither origin/main nor origin/master exists",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (i.localCandidates.length === 1) {
|
|
76
|
+
return { ok: true, ref: i.localCandidates[0], source: "local-default" };
|
|
77
|
+
}
|
|
78
|
+
if (i.localCandidates.length > 1) {
|
|
79
|
+
return { ok: false, reason: `this repo has no origin remote, and both ${i.localCandidates.join(" and ")} exist locally` };
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, reason: "this repo has no origin remote, and neither main nor master exists locally" };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the repo and pick its trunk, then PROVE the pick resolves to a commit.
|
|
85
|
+
*
|
|
86
|
+
* The proof matters as much as the pick: a `--base` for a ref that isn't there would
|
|
87
|
+
* otherwise fail later as "nothing is merged", the exact silence this exists to end.
|
|
88
|
+
* `tried` carries the ref that didn't resolve so the caller can name it.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveBase(run, explicit) {
|
|
91
|
+
const out = (args) => {
|
|
92
|
+
const r = run(args);
|
|
93
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : null;
|
|
94
|
+
};
|
|
95
|
+
const resolves = (ref) => run(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).status === 0;
|
|
96
|
+
/** What the repo's own refs say, or null when `git remote` couldn't be read. */
|
|
97
|
+
const discover = () => {
|
|
98
|
+
// One for-each-ref for all four candidates. Full refnames, not short ones: a
|
|
99
|
+
// local branch literally named `origin/main` would otherwise read as the remote.
|
|
100
|
+
const refs = (out([
|
|
101
|
+
"for-each-ref",
|
|
102
|
+
"--format=%(refname)",
|
|
103
|
+
"refs/remotes/origin/main",
|
|
104
|
+
"refs/remotes/origin/master",
|
|
105
|
+
"refs/heads/main",
|
|
106
|
+
"refs/heads/master",
|
|
107
|
+
]) ?? "")
|
|
108
|
+
.split("\n")
|
|
109
|
+
.map((r) => r.trim())
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
// A FAILED `git remote` must not read as "no origin". That would route the pick to
|
|
112
|
+
// the local candidates and hand back a local `main`/`master` as the trunk that
|
|
113
|
+
// REMOTE branch deletion is then measured against. No remotes at all is exit 0 with
|
|
114
|
+
// empty output, so the two are distinguishable — keep them distinguished.
|
|
115
|
+
const remotes = out(["remote"]);
|
|
116
|
+
if (remotes === null)
|
|
117
|
+
return null;
|
|
118
|
+
return {
|
|
119
|
+
hasOrigin: remotes.split("\n").some((r) => r.trim() === "origin"),
|
|
120
|
+
remoteCandidates: ["main", "master"]
|
|
121
|
+
.filter((b) => refs.includes(`refs/remotes/origin/${b}`))
|
|
122
|
+
.map((b) => `origin/${b}`),
|
|
123
|
+
localCandidates: ["main", "master"].filter((b) => refs.includes(`refs/heads/${b}`)),
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
// --get-all, not --get: with two values `--get` silently returns the last one. The
|
|
127
|
+
// trunk is the evidence behind a delete, so an ambiguous answer is no answer.
|
|
128
|
+
const configured = explicit ? null : out(["config", "--local", "--get-all", BASE_CONFIG_KEY]);
|
|
129
|
+
if (configured !== null && configured.split("\n").filter((l) => l.trim()).length > 1) {
|
|
130
|
+
return { ok: false, reason: `git config ${BASE_CONFIG_KEY} has more than one value`, tried: null };
|
|
131
|
+
}
|
|
132
|
+
const originHead = explicit || configured ? null : out(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
133
|
+
// Only inspect the repo's refs when nothing more explicit answered. Beyond being
|
|
134
|
+
// cheaper, it means a repo whose `git remote` is unreadable still works when the
|
|
135
|
+
// caller named the trunk outright.
|
|
136
|
+
let inputs;
|
|
137
|
+
if (explicit != null || configured != null || originHead) {
|
|
138
|
+
inputs = { explicit, configured, originHead, hasOrigin: false, remoteCandidates: [], localCandidates: [] };
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const found = discover();
|
|
142
|
+
if (!found)
|
|
143
|
+
return { ok: false, reason: "git couldn't list this repo's remotes", tried: null };
|
|
144
|
+
inputs = found;
|
|
145
|
+
}
|
|
146
|
+
const pick = pickBase(inputs);
|
|
147
|
+
if (!pick.ok)
|
|
148
|
+
return { ok: false, reason: pick.reason, tried: null };
|
|
149
|
+
if (resolves(pick.ref))
|
|
150
|
+
return { ok: true, ref: pick.ref, source: pick.source };
|
|
151
|
+
// An INFERRED pick that doesn't resolve falls through; an explicit one does not.
|
|
152
|
+
// `origin/HEAD` outlives the branch it names: rename the trunk on the remote and the
|
|
153
|
+
// symref still reports the deleted `origin/master`, and git older than 2.45 never
|
|
154
|
+
// repairs it on fetch. Hard-failing there would refuse `claim`, `tidy` AND `done` in a
|
|
155
|
+
// repo that is otherwise perfectly healthy, which is a worse outage than the silence
|
|
156
|
+
// this file exists to end. A ref the CALLER named is different: they asked for that one,
|
|
157
|
+
// so a typo gets reported rather than quietly worked around.
|
|
158
|
+
if (pick.source === "origin-head") {
|
|
159
|
+
const found = discover();
|
|
160
|
+
const second = found ? pickBase(found) : { ok: false, reason: "" };
|
|
161
|
+
if (second.ok && resolves(second.ref)) {
|
|
162
|
+
return { ok: true, ref: second.ref, source: second.source, staleOriginHead: pick.ref };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { ok: false, reason: `${pick.ref} (${sourceLabel(pick.source)}) doesn't resolve to a commit`, tried: pick.ref };
|
|
166
|
+
}
|
|
167
|
+
function sourceLabel(s) {
|
|
168
|
+
return s === "flag"
|
|
169
|
+
? "from --base"
|
|
170
|
+
: s === "config"
|
|
171
|
+
? `from git config ${BASE_CONFIG_KEY}`
|
|
172
|
+
: s === "origin-head"
|
|
173
|
+
? "from origin/HEAD"
|
|
174
|
+
: s === "remote-default"
|
|
175
|
+
? "the only default-looking branch on origin"
|
|
176
|
+
: "the only default-looking local branch";
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The lines to print when the trunk can't be resolved. Every one of them is a
|
|
180
|
+
* command the reader can run, because the alternative this replaces was a table
|
|
181
|
+
* of confident falsehoods and no way to tell it was lying.
|
|
182
|
+
*/
|
|
183
|
+
export function baseHelp(result) {
|
|
184
|
+
return [
|
|
185
|
+
`✗ Can't tell which branch is this repo's trunk: ${result.reason}.`,
|
|
186
|
+
` Merge state is measured against it, so without one every branch would read as unmerged.`,
|
|
187
|
+
` Fix it with any of:`,
|
|
188
|
+
` retasc <command> --base origin/<trunk>`,
|
|
189
|
+
` git config --local ${BASE_CONFIG_KEY} origin/<trunk> (per-repo, remembered)`,
|
|
190
|
+
` git remote set-head origin -a (records origin/HEAD from the remote)`,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Is `ref` an ancestor of `base`? THREE outcomes, on purpose:
|
|
195
|
+
* true / false / null for "git couldn't answer".
|
|
196
|
+
*
|
|
197
|
+
* `merge-base --is-ancestor` exits 0 for yes, 1 for no, and 128 for an error
|
|
198
|
+
* (missing ref, unreadable object, a shallow clone that's missing the history).
|
|
199
|
+
* Collapsing 128 into `false` is the RTSC-799 bug in one character, so the
|
|
200
|
+
* unknown is carried out of here as its own value and can never be reaped.
|
|
201
|
+
*/
|
|
202
|
+
export function mergedInto(run, ref, base) {
|
|
203
|
+
const r = run(["merge-base", "--is-ancestor", ref, base]);
|
|
204
|
+
if (r.status === 0)
|
|
205
|
+
return true;
|
|
206
|
+
if (r.status === 1)
|
|
207
|
+
return false;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Is the repo a shallow clone, where ancestry answers can't be trusted?
|
|
212
|
+
* `null` when git couldn't say — same rule as `mergedInto`, and for the same reason:
|
|
213
|
+
* this module's whole job is to stop a failed check from reading as a clean answer,
|
|
214
|
+
* and a suppressed warning is exactly the silence RTSC-799 was made of.
|
|
215
|
+
*/
|
|
216
|
+
export function isShallow(run) {
|
|
217
|
+
const r = run(["rev-parse", "--is-shallow-repository"]);
|
|
218
|
+
if (r.status !== 0)
|
|
219
|
+
return null;
|
|
220
|
+
const out = (r.stdout ?? "").trim();
|
|
221
|
+
return out === "true" ? true : out === "false" ? false : null;
|
|
222
|
+
}
|
package/dist/lib/format.js
CHANGED
|
@@ -308,14 +308,16 @@ function time(ms) {
|
|
|
308
308
|
* front of it: it closed RTSC-706 while that PR was open and unreviewed, printed a tick,
|
|
309
309
|
* and moved on to offering branch deletion. This is the sentence that would have caught it.
|
|
310
310
|
*/
|
|
311
|
-
export function doneReadback(i, merged) {
|
|
311
|
+
export function doneReadback(i, merged, base) {
|
|
312
312
|
const rows = [
|
|
313
313
|
["Issue", `${clean(i.id ?? "—")} ${clean(i.title ?? "")}`.trimEnd()],
|
|
314
314
|
["Status", `${clean(i.status ?? "—")} → done`],
|
|
315
315
|
];
|
|
316
316
|
if (i.assignee)
|
|
317
317
|
rows.push(["Assignee", memberRef(i.assignee)]);
|
|
318
|
+
// `base` is the trunk `done` actually measured against (RTSC-799) — naming it is
|
|
319
|
+
// the difference between a reader trusting this line and being able to check it.
|
|
318
320
|
if (merged !== null)
|
|
319
|
-
rows.push(["Branch", merged ?
|
|
321
|
+
rows.push(["Branch", merged ? `merged into ${base}` : `NOT merged into ${base}`]);
|
|
320
322
|
return labelled(rows);
|
|
321
323
|
}
|
package/dist/lib/session.js
CHANGED
|
@@ -46,7 +46,13 @@ export async function mintSessionKey(opts) {
|
|
|
46
46
|
jsonrpc: "2.0",
|
|
47
47
|
id: MINT_RPC_ID,
|
|
48
48
|
method: "tools/call",
|
|
49
|
-
params: {
|
|
49
|
+
params: {
|
|
50
|
+
name: "mint_session_key",
|
|
51
|
+
arguments: {
|
|
52
|
+
label: opts.label,
|
|
53
|
+
...(opts.cliVersion ? { cliVersion: opts.cliVersion } : {}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
50
56
|
}),
|
|
51
57
|
signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
|
|
52
58
|
});
|
package/dist/lib/tidy.js
CHANGED
|
@@ -19,20 +19,24 @@ export function issueIdFromBranch(branch) {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Decide what to do with a branch from the join of issue status (control plane)
|
|
22
|
-
* and merged-into-
|
|
23
|
-
* - done/canceled + merged → reap (safe to delete; work is on
|
|
22
|
+
* and merged-into-the-trunk (execution plane):
|
|
23
|
+
* - done/canceled + merged → reap (safe to delete; work is on the trunk)
|
|
24
24
|
* - done/canceled + unmerged → orphan (review — work may have landed elsewhere)
|
|
25
25
|
* - todo/doing/blocked → active (keep — work in progress)
|
|
26
26
|
* - no matching issue → untracked (report only — never auto-delete)
|
|
27
27
|
* `status` is null when no issue maps to the branch (off-convention name, or the
|
|
28
28
|
* issue wasn't found).
|
|
29
|
+
*
|
|
30
|
+
* `merged` is null when git couldn't answer (RTSC-799). Only an explicit `true`
|
|
31
|
+
* reaps: "couldn't tell" must never authorize a delete, and it lands in the same
|
|
32
|
+
* needs-a-human bucket as a genuinely unmerged branch.
|
|
29
33
|
*/
|
|
30
34
|
export function classifyBranch(opts) {
|
|
31
35
|
const { status, merged } = opts;
|
|
32
36
|
if (status === null)
|
|
33
37
|
return "untracked";
|
|
34
38
|
if (status === "done" || status === "canceled")
|
|
35
|
-
return merged ? "reap" : "orphan";
|
|
39
|
+
return merged === true ? "reap" : "orphan";
|
|
36
40
|
return "active";
|
|
37
41
|
}
|
|
38
42
|
/**
|
package/dist/proxy.js
CHANGED
|
@@ -15,6 +15,7 @@ import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
|
|
|
15
15
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
16
16
|
import { mintSessionKey, appendFallbackNotice, recordSession, nameWorkspace, RPC_ID } from "./lib/session.js";
|
|
17
17
|
import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
|
|
18
|
+
import { VERSION } from "./version.js";
|
|
18
19
|
import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
19
20
|
import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
|
|
20
21
|
// RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
|
|
@@ -160,7 +161,14 @@ function runReap(issueId) {
|
|
|
160
161
|
log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
|
|
161
162
|
finish();
|
|
162
163
|
});
|
|
163
|
-
|
|
164
|
+
// Log a non-zero exit. `tidy` refuses to guess when it can't work out the repo's
|
|
165
|
+
// trunk (RTSC-799), and a reap that fails every time must not look exactly like a
|
|
166
|
+
// reap that worked — the whole bug was a teardown failing in silence.
|
|
167
|
+
child.on("exit", (code) => {
|
|
168
|
+
if (code)
|
|
169
|
+
log(`reap of ${issueId} exited ${code} — see its output above`);
|
|
170
|
+
finish();
|
|
171
|
+
});
|
|
164
172
|
child.unref?.(); // a pending reap must not keep the proxy alive
|
|
165
173
|
});
|
|
166
174
|
}
|
|
@@ -188,7 +196,16 @@ async function adoptSessionKey() {
|
|
|
188
196
|
if (!KEY)
|
|
189
197
|
return;
|
|
190
198
|
const label = process.env.RETASC_SESSION_LABEL || `${hostname()}#${process.pid}`;
|
|
191
|
-
|
|
199
|
+
// RTSC-861 — report our own version at the one call every proxy makes at startup,
|
|
200
|
+
// so the server never has to infer whether this build is current. A proxy too old to
|
|
201
|
+
// send it is exactly the case the server-side inference exists for.
|
|
202
|
+
const outcome = await mintSessionKey({
|
|
203
|
+
url: MCP_URL,
|
|
204
|
+
key: KEY,
|
|
205
|
+
label,
|
|
206
|
+
cliVersion: VERSION,
|
|
207
|
+
warn: log,
|
|
208
|
+
});
|
|
192
209
|
if (outcome.ok) {
|
|
193
210
|
activeKey = outcome.key; // switch to the session key
|
|
194
211
|
log(`adopted session key "${outcome.session ?? label}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|