@bridge4dev/runner 0.62.0 → 0.63.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/dist/checkpoints.d.ts +11 -0
- package/dist/checkpoints.js +64 -5
- package/dist/git.d.ts +56 -0
- package/dist/git.js +78 -7
- package/dist/gitops.d.ts +29 -0
- package/dist/gitops.js +74 -19
- package/dist/index.js +13 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/checkpoints.d.ts
CHANGED
|
@@ -18,6 +18,17 @@ export interface CheckpointRecord {
|
|
|
18
18
|
kind: CheckpointKind;
|
|
19
19
|
/** HEAD of the worktree when the checkpoint was taken. */
|
|
20
20
|
headSha: string;
|
|
21
|
+
/**
|
|
22
|
+
* #137: there was no commit at all when this point was taken — an empty
|
|
23
|
+
* `headSha` that MEANS something.
|
|
24
|
+
*
|
|
25
|
+
* Written down rather than inferred, because the empty string is already the
|
|
26
|
+
* value `decodeMeta` invents for a record whose metadata it could not read.
|
|
27
|
+
* Those two states need opposite treatment: «nothing was committed yet» can
|
|
28
|
+
* say truthfully what arrived since (all of it), while «we do not know what
|
|
29
|
+
* HEAD was» must not claim the whole history arrived after the point.
|
|
30
|
+
*/
|
|
31
|
+
unborn?: boolean;
|
|
21
32
|
/** Paths that were staged in the project's index at that moment. */
|
|
22
33
|
stagedPaths: string[];
|
|
23
34
|
createdAt: number;
|
package/dist/checkpoints.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { checkpointsDir } from './paths.js';
|
|
7
|
+
import { EMPTY_TREE_SHA } from './gitops.js';
|
|
7
8
|
import { isSecretPath } from './policy.js';
|
|
8
9
|
import { log } from './log.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -410,8 +411,30 @@ async function runBatched(store, worktreePath, indexFile, args, paths) {
|
|
|
410
411
|
async function buildIndex(store, worktreePath, indexFile) {
|
|
411
412
|
fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
|
|
412
413
|
fs.rmSync(indexFile, { force: true });
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
// #137: a repository with no commits has no HEAD to build the index from,
|
|
415
|
+
// and the bare `rev-parse HEAD` threw git's manual page — which the refusal
|
|
416
|
+
// classifier then read as «this folder is not a git repository», so every
|
|
417
|
+
// single step of every session in a fresh `git init` folder wrote that
|
|
418
|
+
// sentence into the feed. It is the wrong sentence twice over: the folder IS
|
|
419
|
+
// a repository, and a restore point in it is perfectly possible.
|
|
420
|
+
//
|
|
421
|
+
// The empty tree is what git itself compares a first commit against, so it is
|
|
422
|
+
// the honest starting index — and `gitStatus` in DIRECT mode has been using
|
|
423
|
+
// exactly this constant for the same reason since session 16.
|
|
424
|
+
//
|
|
425
|
+
// The exit code is READ, not caught away — the same rule `headState` follows
|
|
426
|
+
// in `git.ts`, and for a bigger reason here. A bare `.catch` would read a
|
|
427
|
+
// timeout, an OOM kill or a lost `safe.directory` as «no commits yet» on a
|
|
428
|
+
// repository that has plenty: the point would then be built from the empty
|
|
429
|
+
// tree, look perfectly normal in the feed, and a rewind to it would offer to
|
|
430
|
+
// DELETE every file the checkpoint did not happen to cover. «git could not
|
|
431
|
+
// look» must abort the point, exactly as it did before #137.
|
|
432
|
+
const headSha = await gitIn(worktreePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => sha, (error) => {
|
|
433
|
+
if (error.code === 1)
|
|
434
|
+
return '';
|
|
435
|
+
throw error;
|
|
436
|
+
});
|
|
437
|
+
await gitStore(store, worktreePath, indexFile, 'read-tree', headSha || EMPTY_TREE_SHA);
|
|
415
438
|
const { paths, secrets } = await changedPaths(worktreePath);
|
|
416
439
|
const excluded = new Set(secrets);
|
|
417
440
|
const included = [];
|
|
@@ -496,6 +519,7 @@ function decodeMeta(message) {
|
|
|
496
519
|
return {
|
|
497
520
|
kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
|
|
498
521
|
headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
|
|
522
|
+
...(parsed['unborn'] === true ? { unborn: true } : {}),
|
|
499
523
|
stagedPaths: Array.isArray(parsed['stagedPaths'])
|
|
500
524
|
? parsed['stagedPaths'].filter((p) => typeof p === 'string')
|
|
501
525
|
: [],
|
|
@@ -549,7 +573,13 @@ export async function createCheckpoint(input) {
|
|
|
549
573
|
/** An error on the way to a restore point, read as a reason to report. */
|
|
550
574
|
function checkpointRefusal(sessionId, error) {
|
|
551
575
|
const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
|
|
552
|
-
|
|
576
|
+
// #137: «ambiguous argument 'HEAD'» is git's answer to a repository with no
|
|
577
|
+
// commits in it, and reading it as «not a git repository» produced the one
|
|
578
|
+
// sentence in the feed that was flatly untrue — on a folder the person had
|
|
579
|
+
// just created and bound. An unborn HEAD does not reach here at all any more
|
|
580
|
+
// (`buildIndex` starts from the empty tree), and if some other unborn-HEAD
|
|
581
|
+
// read ever does, «git refused» is the honest bucket for it, not «not a repo».
|
|
582
|
+
if (/not a git repository/i.test(detail)) {
|
|
553
583
|
return { created: false, reason: 'not-a-repo', detail };
|
|
554
584
|
}
|
|
555
585
|
log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
|
|
@@ -573,6 +603,10 @@ async function takeCheckpoint(store, input) {
|
|
|
573
603
|
const meta = {
|
|
574
604
|
kind,
|
|
575
605
|
headSha,
|
|
606
|
+
// Only when it is true: an absent key is what every record written before
|
|
607
|
+
// this release carries, and those were all taken on a repository with a
|
|
608
|
+
// HEAD.
|
|
609
|
+
...(headSha ? {} : { unborn: true }),
|
|
576
610
|
stagedPaths,
|
|
577
611
|
createdAt: Date.now(),
|
|
578
612
|
fileCount: included.length,
|
|
@@ -721,10 +755,35 @@ async function buildPreview(store, indexFile, input) {
|
|
|
721
755
|
const total = restore.length + remove.length + recreate.length;
|
|
722
756
|
let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
|
|
723
757
|
const commitsSince = [];
|
|
724
|
-
|
|
758
|
+
// #137: an empty string is «there was no commit at all when this point was
|
|
759
|
+
// taken» — a real state now that a repository with no commits can hold a
|
|
760
|
+
// session. It must still count as movement: the first commit arriving between
|
|
761
|
+
// the point and the rewind moves HEAD exactly as any later one does, and the
|
|
762
|
+
// old `record.headSha &&` guard read that case as «HEAD has not moved» and
|
|
763
|
+
// let the rewind run without a word.
|
|
764
|
+
if (!blockedReason && record.headSha !== headSha) {
|
|
725
765
|
blockedReason = 'head-moved';
|
|
726
766
|
try {
|
|
727
|
-
|
|
767
|
+
// A range needs two ends. With no commit at all behind the point, «what
|
|
768
|
+
// arrived since» is the whole history that exists — the first commit and
|
|
769
|
+
// whatever followed it — so the range collapses to one end. An empty
|
|
770
|
+
// left side would NOT do that: `..<sha>` means `HEAD..<sha>` to git,
|
|
771
|
+
// which is a different question and usually an empty answer.
|
|
772
|
+
//
|
|
773
|
+
// The one-ended form is used ONLY for a point that recorded «there was
|
|
774
|
+
// nothing here yet». A record whose metadata could not be read carries
|
|
775
|
+
// the same empty `headSha` and means something else entirely — listing
|
|
776
|
+
// the repository's whole history under «what arrived since this point»
|
|
777
|
+
// would be a confident, wrong answer. That record still refuses the
|
|
778
|
+
// rewind, which is the safe direction; it simply names no commits.
|
|
779
|
+
const range = record.headSha
|
|
780
|
+
? `${record.headSha}..${headSha}`
|
|
781
|
+
: record.unborn
|
|
782
|
+
? headSha
|
|
783
|
+
: null;
|
|
784
|
+
const listed = headSha && range
|
|
785
|
+
? await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', range)
|
|
786
|
+
: '';
|
|
728
787
|
for (const line of listed.split('\n')) {
|
|
729
788
|
if (!line.trim())
|
|
730
789
|
continue;
|
package/dist/git.d.ts
CHANGED
|
@@ -30,11 +30,67 @@ export declare class WorktreePrepareError extends Error {
|
|
|
30
30
|
baseSha?: string;
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A repository with no commits cannot hand out a copy of itself (#137).
|
|
35
|
+
*
|
|
36
|
+
* The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
|
|
37
|
+
* package cannot import — it is published to npm on its own, and the import
|
|
38
|
+
* would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
|
|
39
|
+
* Same arrangement as the level-event constants and the session limits: the
|
|
40
|
+
* text lives twice and a test pins the two copies together, because the API
|
|
41
|
+
* refuses this case before a session exists and the runner refuses it again if
|
|
42
|
+
* one ever gets that far — and the person must read one sentence, not two.
|
|
43
|
+
*
|
|
44
|
+
* `git.test.ts` compares this against the shared copy byte for byte.
|
|
45
|
+
*/
|
|
46
|
+
export declare const NO_COMMITS_YET_MESSAGE = "This repository has no commits yet \u2014 start a session in the project folder itself; a branch of its own becomes possible after the first commit";
|
|
47
|
+
/**
|
|
48
|
+
* Where HEAD points — with the three states told apart properly (#137).
|
|
49
|
+
*
|
|
50
|
+
* `rev-parse --abbrev-ref HEAD` cannot answer this and never could. On a
|
|
51
|
+
* repository straight out of `git init` it exits 128 with git's own manual
|
|
52
|
+
* page («ambiguous argument 'HEAD'»), and it ALSO prints the literal word
|
|
53
|
+
* `HEAD` — the same word it prints on a detached HEAD. So the one call that
|
|
54
|
+
* everything here used to be built on both throws where it should not and,
|
|
55
|
+
* caught, answers «detached» for a folder that is plainly on a branch. That is
|
|
56
|
+
* the whole of #137: a directory with no commits in it could not be bound, its
|
|
57
|
+
* first session refused to start, and its restore points reported «this is not
|
|
58
|
+
* a git repository».
|
|
59
|
+
*
|
|
60
|
+
* Two calls answer it honestly, and both are cheap:
|
|
61
|
+
*
|
|
62
|
+
* `symbolic-ref --short HEAD` — the branch NAME, whether or not it has a
|
|
63
|
+
* commit; fails only on a detached HEAD.
|
|
64
|
+
* `rev-parse --verify --quiet HEAD` — is there a commit at all.
|
|
65
|
+
*
|
|
66
|
+
* branch + commit → an ordinary checkout
|
|
67
|
+
* branch, no commit → unborn: a legitimate, fully workable state. The person
|
|
68
|
+
* ran `git init` and has not committed yet; nobody makes
|
|
69
|
+
* that first commit for them (#137, plan S3a).
|
|
70
|
+
* no branch, commit → detached: the refusal that was always meant here.
|
|
71
|
+
* neither → not a repository. Every caller establishes that
|
|
72
|
+
* separately, so this simply reports nothing.
|
|
73
|
+
*/
|
|
74
|
+
export interface HeadState {
|
|
75
|
+
/** The branch HEAD is on, including one that has no commit yet. */
|
|
76
|
+
branch: string | null;
|
|
77
|
+
/** On a branch that has no commit yet — a fresh `git init`. */
|
|
78
|
+
unborn: boolean;
|
|
79
|
+
/** On a commit rather than on a branch. */
|
|
80
|
+
detached: boolean;
|
|
81
|
+
}
|
|
82
|
+
export declare function headState(workspacePath: string): Promise<HeadState>;
|
|
33
83
|
export interface PathValidation {
|
|
34
84
|
ok: boolean;
|
|
35
85
|
exists: boolean;
|
|
36
86
|
isGitRepo: boolean;
|
|
37
87
|
branch?: string;
|
|
88
|
+
/**
|
|
89
|
+
* #137: the branch has no commit on it yet. `branch` above is still its name
|
|
90
|
+
* and the directory still binds — this says «do not expect a HEAD here», and
|
|
91
|
+
* it is what lets every reader downstream stop guessing «detached».
|
|
92
|
+
*/
|
|
93
|
+
unborn?: boolean;
|
|
38
94
|
/** The repository's own main branch, as this machine can see it without the network. */
|
|
39
95
|
defaultBranch?: string;
|
|
40
96
|
error?: string;
|
package/dist/git.js
CHANGED
|
@@ -94,6 +94,44 @@ export class WorktreePrepareError extends Error {
|
|
|
94
94
|
this.baseSha = base.baseSha;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A repository with no commits cannot hand out a copy of itself (#137).
|
|
99
|
+
*
|
|
100
|
+
* The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
|
|
101
|
+
* package cannot import — it is published to npm on its own, and the import
|
|
102
|
+
* would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
|
|
103
|
+
* Same arrangement as the level-event constants and the session limits: the
|
|
104
|
+
* text lives twice and a test pins the two copies together, because the API
|
|
105
|
+
* refuses this case before a session exists and the runner refuses it again if
|
|
106
|
+
* one ever gets that far — and the person must read one sentence, not two.
|
|
107
|
+
*
|
|
108
|
+
* `git.test.ts` compares this against the shared copy byte for byte.
|
|
109
|
+
*/
|
|
110
|
+
// #region no-commits-yet-mirror
|
|
111
|
+
export const NO_COMMITS_YET_MESSAGE = 'This repository has no commits yet — start a session in the project folder itself; a branch of its own becomes possible after the first commit';
|
|
112
|
+
export async function headState(workspacePath) {
|
|
113
|
+
const [symbolic, commit] = await Promise.all([
|
|
114
|
+
git(workspacePath, 'symbolic-ref', '--short', 'HEAD').catch(() => null),
|
|
115
|
+
// `--verify --quiet` is the whole point: an unborn HEAD is exit 1 and
|
|
116
|
+
// SILENCE here, where the plain form is exit 128 and three lines of git.
|
|
117
|
+
//
|
|
118
|
+
// And the exit code is read rather than caught away, the same way the
|
|
119
|
+
// branch probe in `looksHalfCreated` reads it and for the same reason:
|
|
120
|
+
// exit 1 is «there is no commit», while 128, a signal or a
|
|
121
|
+
// dubious-ownership fatal is git failing to LOOK. `unborn` authorises
|
|
122
|
+
// real things — it is what offers the project folder for work and what
|
|
123
|
+
// makes a restore point start from the empty tree — so «could not look»
|
|
124
|
+
// must never arrive here dressed as «no commits yet».
|
|
125
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => ({ sha, absent: false }), (error) => ({
|
|
126
|
+
sha: null,
|
|
127
|
+
absent: error.code === 1,
|
|
128
|
+
})),
|
|
129
|
+
]);
|
|
130
|
+
const branch = symbolic ? symbolic.trim() : '';
|
|
131
|
+
if (!branch)
|
|
132
|
+
return { branch: null, unborn: false, detached: Boolean(commit.sha) };
|
|
133
|
+
return { branch, unborn: commit.absent, detached: false };
|
|
134
|
+
}
|
|
97
135
|
/**
|
|
98
136
|
* Can this runner actually work in this directory — as the user it runs as?
|
|
99
137
|
*
|
|
@@ -190,17 +228,27 @@ export async function validateWorkspacePath(workspacePath) {
|
|
|
190
228
|
`Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
|
|
191
229
|
};
|
|
192
230
|
}
|
|
193
|
-
|
|
231
|
+
// #137: read through `headState`, not `rev-parse --abbrev-ref HEAD`. The old
|
|
232
|
+
// call was the LAST thing this function did and the only one of six branch
|
|
233
|
+
// reads in this file left uncaught — so a directory with `git init` and no
|
|
234
|
+
// commit in it failed binding with git's manual page forwarded to a browser,
|
|
235
|
+
// after every real check above had already passed.
|
|
236
|
+
const head = await headState(workspacePath);
|
|
194
237
|
// Binding is the one moment we are guaranteed to be looking at this repository
|
|
195
238
|
// with somebody waiting for the answer, so it is where the main branch is
|
|
196
239
|
// learned (ADR 0004). `branch` above is «what the folder is on right now» — a
|
|
197
240
|
// drifting value, and the reason #361 exists; these two must not be confused.
|
|
241
|
+
//
|
|
242
|
+
// On a repository with no commits there is no branch to learn: nothing has
|
|
243
|
+
// been pushed anywhere and no conventional branch exists yet, so the binding's
|
|
244
|
+
// `main_branch` stays «not known yet» and learns itself later (ADR 0004).
|
|
198
245
|
const defaultBranch = await remoteDefaultBranch(workspacePath);
|
|
199
246
|
return {
|
|
200
247
|
ok: true,
|
|
201
248
|
exists: true,
|
|
202
249
|
isGitRepo: true,
|
|
203
|
-
branch,
|
|
250
|
+
...(head.branch ? { branch: head.branch } : {}),
|
|
251
|
+
...(head.unborn ? { unborn: true } : {}),
|
|
204
252
|
...(defaultBranch ? { defaultBranch } : {}),
|
|
205
253
|
};
|
|
206
254
|
}
|
|
@@ -308,6 +356,18 @@ export function sanitizeBranch(hint) {
|
|
|
308
356
|
* in the workspace repo, so the work survives worktree cleanup.
|
|
309
357
|
*/
|
|
310
358
|
export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
|
|
359
|
+
// #137: a repository with no commits cannot have a copy of itself, and this
|
|
360
|
+
// is the only honest place to say so. Git refuses both halves of the
|
|
361
|
+
// arrangement — `worktree add -b x main|HEAD` is «invalid reference», and
|
|
362
|
+
// `merge --squash` into an unborn HEAD is «Squash commit into empty head not
|
|
363
|
+
// supported yet» — so a session started this way would be created, would
|
|
364
|
+
// fail here in git's own words, and, had it survived, could never be applied.
|
|
365
|
+
// The plan's decision (S3a) is to refuse early rather than to make the copy
|
|
366
|
+
// work: nobody makes the person's first commit for them.
|
|
367
|
+
const head = await headState(workspacePath);
|
|
368
|
+
if (head.unborn) {
|
|
369
|
+
throw new Error(NO_COMMITS_YET_MESSAGE);
|
|
370
|
+
}
|
|
311
371
|
const short = sessionShortId(sessionId);
|
|
312
372
|
// A ticket group gets a branch named after its tickets so the work reads as
|
|
313
373
|
// one thing in git history. The worktree DIRECTORY stays id-derived — it is
|
|
@@ -743,16 +803,27 @@ export async function prepareDirectWorkspace(workspacePath) {
|
|
|
743
803
|
if (inside !== 'true') {
|
|
744
804
|
throw new Error(`${workspacePath} is not a git work tree`);
|
|
745
805
|
}
|
|
746
|
-
|
|
747
|
-
|
|
806
|
+
// #137: `rev-parse --abbrev-ref HEAD` answered the literal word `HEAD` for a
|
|
807
|
+
// detached checkout AND threw for a repository with no commits, so caught it
|
|
808
|
+
// collapsed both into the detached refusal below — and a folder sitting
|
|
809
|
+
// perfectly well on `main`, one `git init` old, was told to «check a branch
|
|
810
|
+
// out there», which it already had. `headState` tells the two apart.
|
|
811
|
+
const head = await headState(workspacePath);
|
|
812
|
+
if (!head.branch) {
|
|
748
813
|
throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
|
|
749
814
|
}
|
|
750
815
|
const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
|
|
751
|
-
|
|
816
|
+
// No commits yet means no fork point, and that is not a failure: DIRECT mode
|
|
817
|
+
// forks from nothing anyway — the session's workplace IS this folder and this
|
|
818
|
+
// branch. The field simply stays absent, which is what it already did for
|
|
819
|
+
// every other reason a base sha could be missing.
|
|
820
|
+
const baseSha = head.unborn
|
|
821
|
+
? undefined
|
|
822
|
+
: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
|
|
752
823
|
return {
|
|
753
|
-
branch,
|
|
824
|
+
branch: head.branch,
|
|
754
825
|
worktreePath: top || workspacePath,
|
|
755
|
-
baseBranch: branch,
|
|
826
|
+
baseBranch: head.branch,
|
|
756
827
|
...(baseSha ? { baseSha } : {}),
|
|
757
828
|
};
|
|
758
829
|
}
|
package/dist/gitops.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git's own name for «nothing», and the only thing a repository without a
|
|
3
|
+
* single commit can be diffed against. Constant in every git repository ever
|
|
4
|
+
* made — it is the SHA-1 of the empty tree object.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1
7
|
export interface GitFileEntry {
|
|
2
8
|
path: string;
|
|
3
9
|
/** A | M | D | R (git name-status letter). */
|
|
@@ -324,6 +330,22 @@ export interface GitBranchesResult {
|
|
|
324
330
|
branches: GitBranchEntry[];
|
|
325
331
|
/** What the project folder is on right now. */
|
|
326
332
|
currentBranch: string | null;
|
|
333
|
+
/**
|
|
334
|
+
* #137: `currentBranch` above is a branch with no commit on it yet, so it is
|
|
335
|
+
* in no list here — `for-each-ref refs/heads/` has nothing to list until the
|
|
336
|
+
* first commit exists. Without this field the API sees a branch name that is
|
|
337
|
+
* absent from the branches and refuses the session as «Branch main does not
|
|
338
|
+
* exist in this project»; with it, the folder is offered and a copy of its
|
|
339
|
+
* own is refused early and in words.
|
|
340
|
+
*/
|
|
341
|
+
unborn: boolean;
|
|
342
|
+
/**
|
|
343
|
+
* #137: HEAD is a commit rather than a branch. `currentBranch` is null for
|
|
344
|
+
* this AND for «the runner could not read the folder», and the wizard has to
|
|
345
|
+
* tell them apart: one is fixed by checking a branch out, the other is not
|
|
346
|
+
* the person's to fix at all.
|
|
347
|
+
*/
|
|
348
|
+
detached: boolean;
|
|
327
349
|
currentSha: string | null;
|
|
328
350
|
/**
|
|
329
351
|
* The repository's own main branch (ADR 0004) — not the same question as
|
|
@@ -537,6 +559,13 @@ export declare function gitRefs(workspacePath: string): Promise<GitRefsResult>;
|
|
|
537
559
|
export interface WorkspaceStateResult {
|
|
538
560
|
/** The branch the project folder is on; null when HEAD is detached. */
|
|
539
561
|
branch: string | null;
|
|
562
|
+
/**
|
|
563
|
+
* #137: that branch has no commit on it yet. `branch` is still its name and
|
|
564
|
+
* the folder is still perfectly workable — this is what stops every reader
|
|
565
|
+
* downstream from turning «no sha» into «detached HEAD», which is what the
|
|
566
|
+
* run's precondition and the session feed were both doing.
|
|
567
|
+
*/
|
|
568
|
+
unborn: boolean;
|
|
540
569
|
sha: string | null;
|
|
541
570
|
subject: string;
|
|
542
571
|
/** Committer date of that commit, ISO-8601. */
|
package/dist/gitops.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { firstConventionalBranch, remoteDefaultBranch, sanitizeBranch } from './git.js';
|
|
5
|
+
import { firstConventionalBranch, headState, remoteDefaultBranch, sanitizeBranch } from './git.js';
|
|
6
6
|
import { isSecretPath, maskString } from './policy.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -13,7 +13,7 @@ const DIFF_CAP_BYTES = 200_000;
|
|
|
13
13
|
* single commit can be diffed against. Constant in every git repository ever
|
|
14
14
|
* made — it is the SHA-1 of the empty tree object.
|
|
15
15
|
*/
|
|
16
|
-
const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
16
|
+
export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
17
17
|
/**
|
|
18
18
|
* Global git switches every call in this module needs.
|
|
19
19
|
*
|
|
@@ -246,8 +246,21 @@ async function resolveBase(workspacePath, sessionBranch, pinnedBase) {
|
|
|
246
246
|
return { ...(await guessBase(workspacePath, sessionBranch)), pinned: false, baseMissing: false };
|
|
247
247
|
}
|
|
248
248
|
async function guessBase(workspacePath, sessionBranch) {
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
// #137: this read had no `.catch` at all, so on a repository with no commits
|
|
250
|
+
// git's own manual page («ambiguous argument 'HEAD'») travelled out of the
|
|
251
|
+
// runner, through the API and into a 409 toast. Every refusal that can be
|
|
252
|
+
// reached from a screen leaves here as a sentence somebody can act on.
|
|
253
|
+
const head = await headState(workspacePath);
|
|
254
|
+
// #137: NOT the branch-picker's sentence. That one says «start a session in
|
|
255
|
+
// the project folder itself», which is advice for somebody choosing where a
|
|
256
|
+
// session will work — and whoever reads THIS is already inside one, looking
|
|
257
|
+
// at a file list or a history tab. True but unactionable is the exact shape
|
|
258
|
+
// this ticket is about, so each place says the thing its own reader can do.
|
|
259
|
+
if (head.unborn) {
|
|
260
|
+
throw new Error('This repository has no commits yet, so there is nothing to compare this session against. Make the first commit and it will start working.');
|
|
261
|
+
}
|
|
262
|
+
const current = head.branch;
|
|
263
|
+
if (current && current !== sessionBranch) {
|
|
251
264
|
return { baseBranch: current, baseRef: current };
|
|
252
265
|
}
|
|
253
266
|
// One list, one order, one place — the same helper the binding uses to learn
|
|
@@ -255,7 +268,9 @@ async function guessBase(workspacePath, sessionBranch) {
|
|
|
255
268
|
const conventional = await firstConventionalBranch(workspacePath);
|
|
256
269
|
if (conventional)
|
|
257
270
|
return { baseBranch: conventional, baseRef: conventional };
|
|
258
|
-
throw new Error(
|
|
271
|
+
throw new Error(current
|
|
272
|
+
? `Cannot determine the base branch (the project folder is on ${current}, the session's own branch)`
|
|
273
|
+
: 'Cannot determine the base branch: the project folder is not on a branch (a detached HEAD) — check one out there');
|
|
259
274
|
}
|
|
260
275
|
/** How far apart two refs are, in one call: `[behind, ahead]`. */
|
|
261
276
|
async function aheadBehind(cwd, baseRef, branchRef) {
|
|
@@ -1136,7 +1151,27 @@ async function applySessionImpl(input) {
|
|
|
1136
1151
|
error: WORKSPACE_DIRTY_MESSAGE,
|
|
1137
1152
|
};
|
|
1138
1153
|
}
|
|
1139
|
-
|
|
1154
|
+
// #137: read through `headState`. The bare call threw git's manual page into
|
|
1155
|
+
// the «Apply» toast on a repository with no commits, and answered the literal
|
|
1156
|
+
// word `HEAD` on a detached one — a value that then went on to be compared
|
|
1157
|
+
// with a branch name and merged into.
|
|
1158
|
+
const workspaceHead = await headState(workspacePath);
|
|
1159
|
+
if (workspaceHead.unborn) {
|
|
1160
|
+
// Its own sentence, for the same reason as `guessBase` above: whoever
|
|
1161
|
+
// pressed «Apply» is not choosing where to work, and telling them to start
|
|
1162
|
+
// a session in the project folder answers a question they did not ask.
|
|
1163
|
+
return {
|
|
1164
|
+
applied: false,
|
|
1165
|
+
error: 'The project folder has no commits yet, and git cannot fold work into a branch that has none. Make the first commit there, then apply.',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const workspaceBranch = workspaceHead.branch;
|
|
1169
|
+
if (!workspaceBranch) {
|
|
1170
|
+
return {
|
|
1171
|
+
applied: false,
|
|
1172
|
+
error: 'The project folder is not on a branch (a detached HEAD), so there is nowhere to apply this to — check a branch out there first',
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1140
1175
|
if (workspaceBranch === sessionBranch) {
|
|
1141
1176
|
return { applied: false, error: 'The workspace is checked out on the session branch itself' };
|
|
1142
1177
|
}
|
|
@@ -1156,6 +1191,7 @@ async function applySessionImpl(input) {
|
|
|
1156
1191
|
await git(worktreePath, 'commit', '-m', 'chore(devbridge): session changes before apply');
|
|
1157
1192
|
}
|
|
1158
1193
|
const branchSha = await git(workspacePath, 'rev-parse', sessionBranch);
|
|
1194
|
+
// Safe unconditionally now: an unborn HEAD left through the refusal above.
|
|
1159
1195
|
const baseShaBefore = await git(workspacePath, 'rev-parse', 'HEAD');
|
|
1160
1196
|
// Captured BEFORE anything is written, so a failed merge can tell its own
|
|
1161
1197
|
// leftovers from somebody's work that appeared meanwhile.
|
|
@@ -1339,10 +1375,15 @@ const MAX_BRANCHES = 200;
|
|
|
1339
1375
|
* not after the worktree command fails.
|
|
1340
1376
|
*/
|
|
1341
1377
|
export async function gitBranches(workspacePath) {
|
|
1342
|
-
const [rawBranches, rawWorktrees,
|
|
1378
|
+
const [rawBranches, rawWorktrees, head, remotesRaw] = await Promise.all([
|
|
1343
1379
|
git(workspacePath, 'for-each-ref', `--count=${MAX_BRANCHES + 1}`, '--sort=-committerdate', '--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(subject)', 'refs/heads/'),
|
|
1344
1380
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1345
|
-
|
|
1381
|
+
// #137: through `headState`, not `rev-parse --abbrev-ref HEAD`. Caught, that
|
|
1382
|
+
// call answered `null` for a folder one `git init` old and the literal word
|
|
1383
|
+
// `HEAD` for a detached one — so the wizard could not tell a repository
|
|
1384
|
+
// waiting for its first commit from a checkout nobody should commit into,
|
|
1385
|
+
// and greyed out «In the project folder» for both.
|
|
1386
|
+
headState(workspacePath),
|
|
1346
1387
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1347
1388
|
]);
|
|
1348
1389
|
// `worktree list --porcelain` emits stanzas: `worktree <path>` … `branch <ref>`.
|
|
@@ -1380,8 +1421,12 @@ export async function gitBranches(workspacePath) {
|
|
|
1380
1421
|
branch.merged = mergedSet.has(branch.name);
|
|
1381
1422
|
return {
|
|
1382
1423
|
branches,
|
|
1383
|
-
currentBranch:
|
|
1384
|
-
|
|
1424
|
+
currentBranch: head.branch,
|
|
1425
|
+
unborn: head.unborn,
|
|
1426
|
+
detached: head.detached,
|
|
1427
|
+
currentSha: head.unborn
|
|
1428
|
+
? null
|
|
1429
|
+
: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1385
1430
|
defaultBranch: await remoteDefaultBranch(workspacePath).catch(() => null),
|
|
1386
1431
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1387
1432
|
truncated,
|
|
@@ -1886,11 +1931,16 @@ const MAX_REFS = 500;
|
|
|
1886
1931
|
* short name.
|
|
1887
1932
|
*/
|
|
1888
1933
|
export async function gitRefs(workspacePath) {
|
|
1889
|
-
const [raw, rawWorktrees,
|
|
1934
|
+
const [raw, rawWorktrees, head, currentSha, remotesRaw] = await Promise.all([
|
|
1890
1935
|
git(workspacePath, 'for-each-ref', `--count=${MAX_REFS + 1}`, '--sort=-committerdate', '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)%00%(upstream:short)%00%(subject)', 'refs/heads', 'refs/remotes', 'refs/tags'),
|
|
1891
1936
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1892
|
-
|
|
1893
|
-
|
|
1937
|
+
// #137: the fifth reader of the same question, and the one left behind by
|
|
1938
|
+
// the first pass. Caught, so no raw git escapes — but it answered «no
|
|
1939
|
+
// branch» for a folder the wizard, the run's precondition and the session
|
|
1940
|
+
// feed all report as standing on `main`, and one screen disagreeing with
|
|
1941
|
+
// three about the same folder is how this class of bug starts again.
|
|
1942
|
+
headState(workspacePath),
|
|
1943
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1894
1944
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1895
1945
|
]);
|
|
1896
1946
|
const checkedOut = new Map();
|
|
@@ -1925,7 +1975,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1925
1975
|
refs.push({
|
|
1926
1976
|
name: name.slice(0, 200),
|
|
1927
1977
|
type,
|
|
1928
|
-
isHead: type === 'head' && name ===
|
|
1978
|
+
isHead: type === 'head' && name === head.branch,
|
|
1929
1979
|
sha,
|
|
1930
1980
|
subject: maskString(subjectParts.join('\0')).slice(0, 300),
|
|
1931
1981
|
date: (date ?? '').slice(0, 40),
|
|
@@ -1935,7 +1985,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1935
1985
|
}
|
|
1936
1986
|
return {
|
|
1937
1987
|
refs,
|
|
1938
|
-
currentBranch:
|
|
1988
|
+
currentBranch: head.branch,
|
|
1939
1989
|
currentSha,
|
|
1940
1990
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1941
1991
|
truncated,
|
|
@@ -1949,21 +1999,26 @@ export async function gitRefs(workspacePath) {
|
|
|
1949
1999
|
* question behind «how do I know docker was not rebuilt». Read-only.
|
|
1950
2000
|
*/
|
|
1951
2001
|
export async function workspaceState(workspacePath) {
|
|
1952
|
-
const [
|
|
1953
|
-
|
|
1954
|
-
|
|
2002
|
+
const [head, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
|
|
2003
|
+
// #137: `rev-parse --abbrev-ref HEAD` could not tell «no commits yet» from
|
|
2004
|
+
// «detached HEAD» — both arrived here as `null` — and this value is what the
|
|
2005
|
+
// run's precondition reads, so a fresh `git init` was refused with a
|
|
2006
|
+
// sentence about checking a branch out that it could not act on.
|
|
2007
|
+
headState(workspacePath),
|
|
2008
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1955
2009
|
git(workspacePath, 'status', '--porcelain').catch(() => ''),
|
|
1956
2010
|
git(workspacePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
|
|
1957
2011
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1958
2012
|
git(workspacePath, 'log', '-1', '--format=%s%x00%cI', 'HEAD', '--').catch(() => ''),
|
|
1959
2013
|
]);
|
|
1960
|
-
const branch =
|
|
2014
|
+
const branch = head.branch;
|
|
1961
2015
|
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
1962
2016
|
const distance = upstream ? await aheadBehind(workspacePath, upstream, 'HEAD') : null;
|
|
1963
2017
|
const [subject, date] = meta.split('\0');
|
|
1964
2018
|
const dirtyLines = porcelain.split('\n').filter(Boolean);
|
|
1965
2019
|
return {
|
|
1966
2020
|
branch,
|
|
2021
|
+
unborn: head.unborn,
|
|
1967
2022
|
sha,
|
|
1968
2023
|
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
1969
2024
|
date: (date ?? '').slice(0, 40),
|
package/dist/index.js
CHANGED
|
@@ -393,6 +393,19 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
393
393
|
* machine in the fleet is up to date.
|
|
394
394
|
*/
|
|
395
395
|
agentOutsideFolder: true,
|
|
396
|
+
/**
|
|
397
|
+
* #137: this runner can read a repository that has no commits yet.
|
|
398
|
+
*
|
|
399
|
+
* Announced for one reason, and it is a message rather than a refusal: when
|
|
400
|
+
* an OLDER runner meets that repository it answers with git's own manual
|
|
401
|
+
* page, and the API turns anything it cannot parse into «run `git status`
|
|
402
|
+
* on that server and see». For this particular case that advice is a dead
|
|
403
|
+
* end — `git status` there prints «On branch main / No commits yet», which
|
|
404
|
+
* looks perfectly healthy, and the person is none the wiser. With this flag
|
|
405
|
+
* the API can say the true thing instead: the runner on that machine is too
|
|
406
|
+
* old for this, update it.
|
|
407
|
+
*/
|
|
408
|
+
unbornHead: true,
|
|
396
409
|
/**
|
|
397
410
|
* Session 16: git is git.
|
|
398
411
|
*
|
package/dist/protocol.d.ts
CHANGED
|
@@ -783,6 +783,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
783
783
|
branchPlan?: unknown;
|
|
784
784
|
}>, "many">;
|
|
785
785
|
}, "strip", z.ZodTypeAny, {
|
|
786
|
+
type: "hello_ack";
|
|
786
787
|
sessions: {
|
|
787
788
|
mode: "ask" | "plan" | "auto" | "full";
|
|
788
789
|
agent: "CLAUDE" | "CODEX";
|
|
@@ -835,11 +836,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
835
836
|
baseSha?: string | undefined;
|
|
836
837
|
} | undefined;
|
|
837
838
|
}[];
|
|
838
|
-
type: "hello_ack";
|
|
839
839
|
serverName: string;
|
|
840
840
|
serverId: string;
|
|
841
841
|
maxSessions?: number | undefined;
|
|
842
842
|
}, {
|
|
843
|
+
type: "hello_ack";
|
|
843
844
|
sessions: {
|
|
844
845
|
agent: "CLAUDE" | "CODEX";
|
|
845
846
|
status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
|
|
@@ -887,7 +888,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
887
888
|
branchHint?: unknown;
|
|
888
889
|
branchPlan?: unknown;
|
|
889
890
|
}[];
|
|
890
|
-
type: "hello_ack";
|
|
891
891
|
serverName: string;
|
|
892
892
|
serverId: string;
|
|
893
893
|
maxSessions?: unknown;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.63.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED