specrails-desktop 2.29.2 → 2.29.4
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/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
- package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
- package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
- package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
- package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
- package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
- package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
- package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
- package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
- package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
- package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
- package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
- package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
- package/client/dist/assets/agent-BXc59ARK2.js +1 -0
- package/client/dist/assets/agent-BbIFXdDq.js +1 -0
- package/client/dist/assets/agent-BqEfA5u5.js +1 -0
- package/client/dist/assets/agent-BtGzgTC4.js +1 -0
- package/client/dist/assets/agent-CIhys_5d.js +1 -0
- package/client/dist/assets/agent-CJPaqsVR.js +1 -0
- package/client/dist/assets/agent-n-hY4GuL.js +1 -0
- package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
- package/client/dist/assets/common-6MLbD-jM.js +1 -0
- package/client/dist/assets/common-BKGEu4rm.js +1 -0
- package/client/dist/assets/common-BNbnv8cE2.js +1 -0
- package/client/dist/assets/common-BqGWtDbt.js +1 -0
- package/client/dist/assets/common-DBtTzeYm2.js +1 -0
- package/client/dist/assets/common-DEW3Nr8n.js +1 -0
- package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
- package/client/dist/assets/common-mkrEcW-Y.js +1 -0
- package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
- package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
- package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
- package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
- package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
- package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
- package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
- package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
- package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
- package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
- package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
- package/client/dist/assets/index-BT6xDQjB.css +2 -0
- package/client/dist/assets/index-CCq_kIrF.js +76 -0
- package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
- package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
- package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
- package/client/dist/index.html +14 -14
- package/docs/internals/safe-pr-review-flow.md +250 -38
- package/package.json +1 -1
- package/server/dist/active-pr-continuation.js +352 -53
- package/server/dist/agent-chat-manager.js +29 -12
- package/server/dist/agent-operator-prompt.js +9 -0
- package/server/dist/agent-store.js +19 -4
- package/server/dist/db.js +21 -0
- package/server/dist/mcp/tools/rails.js +8 -0
- package/server/dist/pr-lifecycle.js +100 -8
- package/server/dist/project-git.js +68 -3
- package/server/dist/rail-isolated-launch.js +719 -124
- package/server/dist/rail-pr-decision.js +1073 -181
- package/server/dist/rail-pr-recovery-git.js +235 -0
- package/server/dist/rail-pr-store.js +120 -9
- package/server/dist/rail-worktree-release.js +376 -4
- package/server/dist/rails-router.js +196 -10
- package/server/dist/worktree-manager.js +149 -18
- package/server/dist/worktree-overlay.js +166 -4
- package/client/dist/assets/agent-4rmXoo_p.js +0 -1
- package/client/dist/assets/agent-BxZuNGih.js +0 -1
- package/client/dist/assets/agent-C3uXOYv_.js +0 -1
- package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
- package/client/dist/assets/agent-CASKrhG12.js +0 -1
- package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
- package/client/dist/assets/agent-DitUxYh2.js +0 -1
- package/client/dist/assets/agent-Doow4X7o.js +0 -1
- package/client/dist/assets/common-CD1QHriA2.js +0 -1
- package/client/dist/assets/common-CwwPaRxx.js +0 -1
- package/client/dist/assets/common-Dg2jaX7d.js +0 -1
- package/client/dist/assets/common-DuBwj6tK.js +0 -1
- package/client/dist/assets/common-MRj9MHcc.js +0 -1
- package/client/dist/assets/common-O4ZpnMLp.js +0 -1
- package/client/dist/assets/common-fyeF0v7C2.js +0 -1
- package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
- package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
- package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
- package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
- package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
- package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
- package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
- package/client/dist/assets/dashboard-owcDucDi.js +0 -1
- package/client/dist/assets/index-BflYQpWm.css +0 -2
- package/client/dist/assets/index-C1_-BTXy.js +0 -72
package/server/dist/db.js
CHANGED
|
@@ -1405,6 +1405,27 @@ const MIGRATIONS = [
|
|
|
1405
1405
|
}
|
|
1406
1406
|
backfillTerminalRailPrTicketEffects(db);
|
|
1407
1407
|
},
|
|
1408
|
+
// Migration 53: an allocation rollback is the one legal way a previously
|
|
1409
|
+
// superseded delivery can become active again. Persist the failed replacement
|
|
1410
|
+
// id on the restored predecessor so clients can distinguish that explicit
|
|
1411
|
+
// rollback from an ordinary stale non-terminal replay.
|
|
1412
|
+
(db) => {
|
|
1413
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
|
|
1414
|
+
.map((row) => row.name));
|
|
1415
|
+
if (!cols.has('restored_from_delivery_id')) {
|
|
1416
|
+
db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN restored_from_delivery_id TEXT`);
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
// Migration 54: automatic worktree release quarantines authenticated overlay
|
|
1420
|
+
// roots instead of deleting them. Persist the bounded archive locations so
|
|
1421
|
+
// recovery remains discoverable after restart and card refresh.
|
|
1422
|
+
(db) => {
|
|
1423
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
|
|
1424
|
+
.map((row) => row.name));
|
|
1425
|
+
if (!cols.has('safety_archives')) {
|
|
1426
|
+
db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN safety_archives TEXT NOT NULL DEFAULT '[]'`);
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1408
1429
|
];
|
|
1409
1430
|
function applyMigrations(db) {
|
|
1410
1431
|
// Ensure the migrations table exists (migration 1 creates it, but we need
|
|
@@ -120,6 +120,12 @@ function railsTools() {
|
|
|
120
120
|
.enum(['low', 'medium', 'high'])
|
|
121
121
|
.optional()
|
|
122
122
|
.describe('Reasoning-effort tier for loop launches (launch, loop mode). From the in-app agent chat, omitting it defaults to the launching conversation\'s effort when that is low/medium/high.'),
|
|
123
|
+
targetPrNumber: zod_1.z
|
|
124
|
+
.number()
|
|
125
|
+
.int()
|
|
126
|
+
.min(1)
|
|
127
|
+
.optional()
|
|
128
|
+
.describe('Deliver INTO an existing open PR instead of creating a new one (launch). When the user names an existing PR ("extend PR #151"), pass its number here — the rail then works on that PR\'s head branch and settle pushes to it; NEVER launch without it in that case (a plain launch would create a duplicate PR). The PR must be open and same-repo (fork PRs are rejected with target_pr_fork).'),
|
|
123
129
|
},
|
|
124
130
|
async handler(ctx, args) {
|
|
125
131
|
const base = `${(0, types_1.projectPath)(ctx, args.projectId)}/rails`;
|
|
@@ -206,6 +212,8 @@ function railsTools() {
|
|
|
206
212
|
body.loopId = args.loopId;
|
|
207
213
|
if (args.reasoning_effort !== undefined)
|
|
208
214
|
body.reasoning_effort = args.reasoning_effort;
|
|
215
|
+
if (args.targetPrNumber !== undefined)
|
|
216
|
+
body.targetPrNumber = args.targetPrNumber;
|
|
209
217
|
// Origin link (safe-pr-review-flow): a launch driven by the in-app
|
|
210
218
|
// agent carries its conversation id (from the loopback header, via
|
|
211
219
|
// ctx) so the PR decision card is posted back into that conversation.
|
|
@@ -1,16 +1,97 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PR_LIFECYCLE_JSON_FIELDS = void 0;
|
|
4
|
+
exports.verifyPushRemoteForPr = verifyPushRemoteForPr;
|
|
4
5
|
exports.observePrLifecycle = observePrLifecycle;
|
|
6
|
+
exports.matchesRecordedPrIdentity = matchesRecordedPrIdentity;
|
|
5
7
|
exports.isExactOpenPr = isExactOpenPr;
|
|
6
8
|
const SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
7
|
-
exports.PR_LIFECYCLE_JSON_FIELDS = 'state,isDraft,headRefName,baseRefName,headRefOid,mergeCommit,commits';
|
|
9
|
+
exports.PR_LIFECYCLE_JSON_FIELDS = 'state,isDraft,headRefName,baseRefName,isCrossRepository,headRefOid,mergeCommit,commits';
|
|
8
10
|
function firstLine(result) {
|
|
9
11
|
return (result.stderr.trim() || result.stdout.trim()).split('\n')[0].trim().slice(0, 512);
|
|
10
12
|
}
|
|
11
13
|
function oid(value) {
|
|
12
14
|
return typeof value === 'string' && SHA_RE.test(value) ? value.toLowerCase() : null;
|
|
13
15
|
}
|
|
16
|
+
function githubRepositoryIdentity(rawUrl, kind) {
|
|
17
|
+
const raw = rawUrl.trim();
|
|
18
|
+
if (!raw || raw.includes('\n') || raw.includes('\r'))
|
|
19
|
+
return null;
|
|
20
|
+
let host = '';
|
|
21
|
+
let pathname = '';
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(raw);
|
|
24
|
+
if (!['http:', 'https:', 'ssh:', 'git:'].includes(parsed.protocol))
|
|
25
|
+
return null;
|
|
26
|
+
// The verified target is later passed as an argv element. Never carry
|
|
27
|
+
// embedded credentials or token-like URL suffixes into a child process or
|
|
28
|
+
// its timeout/error diagnostics; credential helpers remain the only
|
|
29
|
+
// supported authentication channel. SSH's conventional `git` username is
|
|
30
|
+
// identity, not a secret, and is safe to retain.
|
|
31
|
+
if (parsed.password || parsed.search || parsed.hash)
|
|
32
|
+
return null;
|
|
33
|
+
if (parsed.username && !(parsed.protocol === 'ssh:' && parsed.username === 'git'))
|
|
34
|
+
return null;
|
|
35
|
+
host = parsed.hostname.toLowerCase();
|
|
36
|
+
pathname = parsed.pathname;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// SCP-style Git URL: git@github.com:owner/repository.git
|
|
40
|
+
const scp = /^(?:git@)?([^@:/\s]+):(.+)$/.exec(raw);
|
|
41
|
+
if (!scp)
|
|
42
|
+
return null;
|
|
43
|
+
host = scp[1].toLowerCase();
|
|
44
|
+
pathname = scp[2];
|
|
45
|
+
if (pathname.includes('?') || pathname.includes('#'))
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
49
|
+
if (kind === 'pr') {
|
|
50
|
+
if (segments.length !== 4 || segments[2] !== 'pull' || !/^\d+$/.test(segments[3]))
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
else if (segments.length !== 2) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const owner = segments[0]?.toLowerCase();
|
|
57
|
+
const repository = segments[1]?.replace(/\.git$/i, '').toLowerCase();
|
|
58
|
+
return host && owner && repository ? `${host}/${owner}/${repository}` : null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Prove that the local push remote names the repository that owns a same-repo
|
|
62
|
+
* PR. Branch names alone are insufficient: another clone may call its fork
|
|
63
|
+
* `origin`, which would otherwise receive the exact recovery refspec.
|
|
64
|
+
*/
|
|
65
|
+
async function verifyPushRemoteForPr(exec, repoDir, prUrl, remote = 'origin') {
|
|
66
|
+
const expected = githubRepositoryIdentity(prUrl, 'pr');
|
|
67
|
+
if (!expected)
|
|
68
|
+
return { ok: false, detail: 'the recorded PR URL does not identify an exact GitHub repository' };
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = await exec.run('git', ['remote', 'get-url', '--push', '--all', remote], repoDir);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return { ok: false, detail: `the ${remote} push remote could not be inspected: ${err instanceof Error ? err.message : String(err)}` };
|
|
75
|
+
}
|
|
76
|
+
if (result.code !== 0) {
|
|
77
|
+
return { ok: false, detail: `the ${remote} push remote could not be inspected: ${firstLine(result) || `exit ${result.code}`}` };
|
|
78
|
+
}
|
|
79
|
+
const pushTargets = result.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
80
|
+
if (pushTargets.length !== 1) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
detail: `the ${remote} push remote must resolve to exactly one URL; found ${pushTargets.length}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const pushTarget = pushTargets[0];
|
|
87
|
+
const actual = githubRepositoryIdentity(pushTarget, 'remote');
|
|
88
|
+
if (!actual)
|
|
89
|
+
return { ok: false, detail: `the ${remote} push remote is not an unambiguous GitHub repository URL` };
|
|
90
|
+
if (actual !== expected) {
|
|
91
|
+
return { ok: false, detail: `the ${remote} push remote does not own the recorded PR repository` };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, identity: actual, pushTarget };
|
|
94
|
+
}
|
|
14
95
|
/**
|
|
15
96
|
* Observe one exact PR and, when requested, whether its immutable commit/merge
|
|
16
97
|
* evidence contains a delivery SHA. The helper never guesses from a ledger row:
|
|
@@ -42,11 +123,15 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
|
|
|
42
123
|
// A merged PR's live head branch can advance after the merge. Its current
|
|
43
124
|
// headRefOid is therefore NOT proof that the merge included that object.
|
|
44
125
|
// Only the PR's commit snapshot / merge commit are valid MERGED evidence.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
126
|
+
// While OPEN, delivery verification is intentionally stronger than mere
|
|
127
|
+
// ancestry: the remote PR head itself must still be the frozen delivery
|
|
128
|
+
// object. Otherwise a concurrent writer could advance/substitute the head
|
|
129
|
+
// and a stale card would incorrectly report our exact push as verified.
|
|
130
|
+
// CLOSED/MERGED no longer have a mutable review head, so their immutable PR
|
|
131
|
+
// commit snapshot (or merge object) is the relevant inclusion evidence.
|
|
132
|
+
const observedOids = new Set((normalizedState === 'OPEN'
|
|
133
|
+
? [headRefOid]
|
|
134
|
+
: [mergeCommitOid, ...commitOids]).filter((value) => value !== null));
|
|
50
135
|
const includesExpectedSha = expected === null
|
|
51
136
|
? null
|
|
52
137
|
: observedOids.has(expected)
|
|
@@ -60,6 +145,7 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
|
|
|
60
145
|
isDraft: parsed.isDraft === true,
|
|
61
146
|
headRefName: typeof parsed.headRefName === 'string' ? parsed.headRefName : null,
|
|
62
147
|
baseRefName: typeof parsed.baseRefName === 'string' ? parsed.baseRefName : null,
|
|
148
|
+
isCrossRepository: typeof parsed.isCrossRepository === 'boolean' ? parsed.isCrossRepository : null,
|
|
63
149
|
headRefOid,
|
|
64
150
|
mergeCommitOid,
|
|
65
151
|
commitOids,
|
|
@@ -70,8 +156,14 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
|
|
|
70
156
|
return { ok: false, detail: 'unparseable gh pr view output' };
|
|
71
157
|
}
|
|
72
158
|
}
|
|
159
|
+
/** Head/base identity is required in every lifecycle state. A PR retargeted to
|
|
160
|
+
* another base must never satisfy delivery merely because it still lists the
|
|
161
|
+
* expected commit. */
|
|
162
|
+
function matchesRecordedPrIdentity(observation, branch, baseBranch) {
|
|
163
|
+
return observation.ok && observation.isCrossRepository === false &&
|
|
164
|
+
observation.headRefName === branch && observation.baseRefName === baseBranch;
|
|
165
|
+
}
|
|
73
166
|
/** OPEN is continuation-safe only for the same recorded head/base identity. */
|
|
74
167
|
function isExactOpenPr(observation, branch, baseBranch) {
|
|
75
|
-
return observation.ok && observation.state === 'OPEN'
|
|
76
|
-
observation.headRefName === branch && observation.baseRefName === baseBranch;
|
|
168
|
+
return observation.ok && matchesRecordedPrIdentity(observation, branch, baseBranch) && observation.state === 'OPEN';
|
|
77
169
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseWorktreePorcelain = parseWorktreePorcelain;
|
|
4
|
+
exports.inspectProjectCheckoutCleanliness = inspectProjectCheckoutCleanliness;
|
|
4
5
|
exports.getProjectGitInfo = getProjectGitInfo;
|
|
5
6
|
exports.compactCheckoutError = compactCheckoutError;
|
|
6
7
|
exports.checkoutProjectBranch = checkoutProjectBranch;
|
|
@@ -17,6 +18,7 @@ const integration_branch_1 = require("./integration-branch");
|
|
|
17
18
|
// against `git branch` output, so no user string can smuggle flags or refs.
|
|
18
19
|
const GIT_TIMEOUT_MS = 10_000;
|
|
19
20
|
const GIT_MAX_BUFFER = 4 * 1024 * 1024;
|
|
21
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
20
22
|
function git(repoDir, args) {
|
|
21
23
|
return new Promise((resolve, reject) => {
|
|
22
24
|
(0, child_process_1.execFile)('git', args, { cwd: repoDir, env: file_provenance_1.GIT_EXEC_ENV, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true }, (err, stdout, stderr) => {
|
|
@@ -57,6 +59,25 @@ async function listLocalBranches(repoDir) {
|
|
|
57
59
|
const out = await git(repoDir, ['branch', '--format=%(refname:short)']);
|
|
58
60
|
return out.split('\n').map((b) => b.trim()).filter(Boolean);
|
|
59
61
|
}
|
|
62
|
+
/** Lossless checkout preflight. Unlike the dashboard-oriented aggregate info,
|
|
63
|
+
* this result is tri-state: an unreadable status is never interpreted as a
|
|
64
|
+
* clean checkout. */
|
|
65
|
+
async function inspectProjectCheckoutCleanliness(repoDir) {
|
|
66
|
+
try {
|
|
67
|
+
// Tracked modifications only. `git checkout` never loses untracked files —
|
|
68
|
+
// app-owned state (`.specrails/local-tickets.json`) or editor droppings
|
|
69
|
+
// must not permanently block Checkout; a genuine untracked collision still
|
|
70
|
+
// aborts inside git itself and surfaces as a failed checkout.
|
|
71
|
+
const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
72
|
+
return { ok: true, clean: status === '' };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
detail: `Working tree cleanliness could not be verified: ${err instanceof Error ? err.message : String(err)}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
60
81
|
async function getProjectGitInfo(repoDir) {
|
|
61
82
|
try {
|
|
62
83
|
await git(repoDir, ['rev-parse', '--is-inside-work-tree']);
|
|
@@ -159,14 +180,22 @@ async function remoteBranchExists(repoDir, branch) {
|
|
|
159
180
|
/** Checkout a PR/review branch into the user's main repo. Unlike the small
|
|
160
181
|
* AgentGitBar switcher, this path may materialize a local tracking branch from
|
|
161
182
|
* `origin/<branch>` because a PR delivery can be remote-only after worktree
|
|
162
|
-
* cleanup or a fresh clone.
|
|
163
|
-
|
|
183
|
+
* cleanup or a fresh clone. When supplied, `expectedSha` is an immutable
|
|
184
|
+
* delivery lease: a divergent local/remote branch is preserved and checkout
|
|
185
|
+
* succeeds only when the final branch HEAD equals that exact object. It never
|
|
186
|
+
* overwrites dirty local work. */
|
|
187
|
+
async function checkoutProjectReviewBranch(repoDir, branch, expectedSha) {
|
|
164
188
|
const target = branch.trim();
|
|
165
189
|
if (!(0, integration_branch_1.isValidBranchName)(target))
|
|
166
190
|
return { ok: false, error: `Invalid branch name: ${branch}` };
|
|
191
|
+
const expected = expectedSha?.trim().toLowerCase() ?? null;
|
|
192
|
+
if (expected !== null && !COMMIT_SHA_RE.test(expected)) {
|
|
193
|
+
return { ok: false, error: 'The verified delivery commit is invalid; checkout was not attempted.' };
|
|
194
|
+
}
|
|
167
195
|
let dirty = false;
|
|
168
196
|
try {
|
|
169
|
-
|
|
197
|
+
// Tracked modifications only — see inspectProjectCheckoutCleanliness.
|
|
198
|
+
dirty = (await git(repoDir, ['status', '--porcelain', '--untracked-files=no'])) !== '';
|
|
170
199
|
}
|
|
171
200
|
catch (err) {
|
|
172
201
|
return { ok: false, error: err instanceof Error ? err.message : 'git status failed' };
|
|
@@ -185,9 +214,27 @@ async function checkoutProjectReviewBranch(repoDir, branch) {
|
|
|
185
214
|
}
|
|
186
215
|
try {
|
|
187
216
|
if (branches.includes(target)) {
|
|
217
|
+
if (expected) {
|
|
218
|
+
const localSha = (await git(repoDir, ['rev-parse', '--verify', `refs/heads/${target}`])).toLowerCase();
|
|
219
|
+
if (localSha !== expected) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: `Local branch '${target}' points to ${localSha.slice(0, 8)}, not verified delivery ${expected.slice(0, 8)}. It was preserved and not checked out.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
188
226
|
await git(repoDir, ['checkout', target]);
|
|
189
227
|
}
|
|
190
228
|
else if (await remoteBranchExists(repoDir, target)) {
|
|
229
|
+
if (expected) {
|
|
230
|
+
const remoteSha = (await git(repoDir, ['rev-parse', '--verify', `refs/remotes/origin/${target}`])).toLowerCase();
|
|
231
|
+
if (remoteSha !== expected) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: `Remote branch 'origin/${target}' points to ${remoteSha.slice(0, 8)}, not verified delivery ${expected.slice(0, 8)}. Checkout was not attempted.`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
191
238
|
await git(repoDir, ['checkout', '-b', target, '--track', `origin/${target}`]);
|
|
192
239
|
}
|
|
193
240
|
else {
|
|
@@ -197,6 +244,24 @@ async function checkoutProjectReviewBranch(repoDir, branch) {
|
|
|
197
244
|
catch (err) {
|
|
198
245
|
return { ok: false, error: compactCheckoutError(err instanceof Error ? err.message : 'git checkout failed') };
|
|
199
246
|
}
|
|
247
|
+
if (expected) {
|
|
248
|
+
try {
|
|
249
|
+
const [currentBranch, currentSha] = await Promise.all([
|
|
250
|
+
git(repoDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
251
|
+
git(repoDir, ['rev-parse', '--verify', 'HEAD']),
|
|
252
|
+
]);
|
|
253
|
+
if (currentBranch !== target || currentSha.toLowerCase() !== expected) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: `Checkout changed concurrently and did not land on verified delivery ${expected.slice(0, 8)}. No reset or cleanup was performed.`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return { ok: true };
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return { ok: false, error: compactCheckoutError(err instanceof Error ? err.message : 'git checkout verification failed') };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
200
265
|
// A failed ff-only pull should not undo a successful checkout; the branch is
|
|
201
266
|
// already where the user can inspect/fix it. Surface only hard checkout errors.
|
|
202
267
|
await git(repoDir, ['pull', '--ff-only']).catch(() => { });
|