@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.
@@ -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;
@@ -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
- const headSha = await gitIn(worktreePath, 'rev-parse', 'HEAD');
414
- await gitStore(store, worktreePath, indexFile, 'read-tree', headSha);
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
- if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
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
- if (!blockedReason && record.headSha && record.headSha !== headSha) {
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
- const listed = await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', `${record.headSha}..${headSha}`);
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
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
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
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
747
- if (!branch || branch === 'HEAD') {
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
- const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
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
- const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
250
- if (current !== sessionBranch && current !== 'HEAD') {
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(`Cannot determine the base branch (workspace is on ${current})`);
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
- const workspaceBranch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
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, currentBranch, remotesRaw] = await Promise.all([
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
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
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: currentBranch === 'HEAD' ? null : currentBranch,
1384
- currentSha: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
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, currentBranch, currentSha, remotesRaw] = await Promise.all([
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
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
1893
- git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
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 === currentBranch,
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: currentBranch === 'HEAD' ? null : 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 [branchRaw, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
1953
- git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
1954
- git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
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 = branchRaw === 'HEAD' ? null : branchRaw;
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
  *
@@ -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.62.0";
1
+ export declare const RUNNER_VERSION = "0.63.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.62.0';
2
+ export const RUNNER_VERSION = '0.63.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",