@bridge4dev/runner 0.62.0 → 0.64.1

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;
@@ -3,7 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
+ import { cleanGitEnv } from './environment.js';
6
7
  import { checkpointsDir } from './paths.js';
8
+ import { EMPTY_TREE_SHA } from './gitops.js';
7
9
  import { isSecretPath } from './policy.js';
8
10
  import { log } from './log.js';
9
11
  const execFileAsync = promisify(execFile);
@@ -89,7 +91,7 @@ async function gitIn(cwd, ...args) {
89
91
  // The project's own environment must not leak in: a GIT_INDEX_FILE or
90
92
  // GIT_DIR inherited from a parent process would silently retarget every
91
93
  // command below at the wrong repository.
92
- env: cleanEnv(),
94
+ env: cleanGitEnv(),
93
95
  });
94
96
  return stdout.replace(/\n$/, '');
95
97
  }
@@ -106,7 +108,7 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
106
108
  timeout: GIT_TIMEOUT_MS,
107
109
  maxBuffer: 32 * 1024 * 1024,
108
110
  env: {
109
- ...cleanEnv(),
111
+ ...cleanGitEnv(),
110
112
  GIT_DIR: store,
111
113
  GIT_WORK_TREE: worktreePath,
112
114
  GIT_INDEX_FILE: indexFile,
@@ -118,22 +120,13 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
118
120
  });
119
121
  return stdout.replace(/\n$/, '');
120
122
  }
121
- function cleanEnv() {
122
- const env = { ...process.env };
123
- delete env['GIT_DIR'];
124
- delete env['GIT_WORK_TREE'];
125
- delete env['GIT_INDEX_FILE'];
126
- delete env['GIT_OBJECT_DIRECTORY'];
127
- delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
128
- return env;
129
- }
130
123
  async function ensureStore(worktreePath) {
131
124
  const { store, objectDir } = await storeFor(worktreePath);
132
125
  if (!fs.existsSync(store)) {
133
126
  fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
134
127
  await execFileAsync('git', ['init', '--quiet', '--bare', store], {
135
128
  timeout: GIT_TIMEOUT_MS,
136
- env: cleanEnv(),
129
+ env: cleanGitEnv(),
137
130
  });
138
131
  fs.chmodSync(store, 0o700);
139
132
  }
@@ -325,7 +318,7 @@ async function gitRefs(store, ...args) {
325
318
  const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
326
319
  timeout: GIT_TIMEOUT_MS,
327
320
  maxBuffer: 32 * 1024 * 1024,
328
- env: cleanEnv(),
321
+ env: cleanGitEnv(),
329
322
  });
330
323
  return stdout.replace(/\n$/, '');
331
324
  }
@@ -410,8 +403,30 @@ async function runBatched(store, worktreePath, indexFile, args, paths) {
410
403
  async function buildIndex(store, worktreePath, indexFile) {
411
404
  fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
412
405
  fs.rmSync(indexFile, { force: true });
413
- const headSha = await gitIn(worktreePath, 'rev-parse', 'HEAD');
414
- await gitStore(store, worktreePath, indexFile, 'read-tree', headSha);
406
+ // #137: a repository with no commits has no HEAD to build the index from,
407
+ // and the bare `rev-parse HEAD` threw git's manual page — which the refusal
408
+ // classifier then read as «this folder is not a git repository», so every
409
+ // single step of every session in a fresh `git init` folder wrote that
410
+ // sentence into the feed. It is the wrong sentence twice over: the folder IS
411
+ // a repository, and a restore point in it is perfectly possible.
412
+ //
413
+ // The empty tree is what git itself compares a first commit against, so it is
414
+ // the honest starting index — and `gitStatus` in DIRECT mode has been using
415
+ // exactly this constant for the same reason since session 16.
416
+ //
417
+ // The exit code is READ, not caught away — the same rule `headState` follows
418
+ // in `git.ts`, and for a bigger reason here. A bare `.catch` would read a
419
+ // timeout, an OOM kill or a lost `safe.directory` as «no commits yet» on a
420
+ // repository that has plenty: the point would then be built from the empty
421
+ // tree, look perfectly normal in the feed, and a rewind to it would offer to
422
+ // DELETE every file the checkpoint did not happen to cover. «git could not
423
+ // look» must abort the point, exactly as it did before #137.
424
+ const headSha = await gitIn(worktreePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => sha, (error) => {
425
+ if (error.code === 1)
426
+ return '';
427
+ throw error;
428
+ });
429
+ await gitStore(store, worktreePath, indexFile, 'read-tree', headSha || EMPTY_TREE_SHA);
415
430
  const { paths, secrets } = await changedPaths(worktreePath);
416
431
  const excluded = new Set(secrets);
417
432
  const included = [];
@@ -496,6 +511,7 @@ function decodeMeta(message) {
496
511
  return {
497
512
  kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
498
513
  headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
514
+ ...(parsed['unborn'] === true ? { unborn: true } : {}),
499
515
  stagedPaths: Array.isArray(parsed['stagedPaths'])
500
516
  ? parsed['stagedPaths'].filter((p) => typeof p === 'string')
501
517
  : [],
@@ -549,7 +565,13 @@ export async function createCheckpoint(input) {
549
565
  /** An error on the way to a restore point, read as a reason to report. */
550
566
  function checkpointRefusal(sessionId, error) {
551
567
  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)) {
568
+ // #137: «ambiguous argument 'HEAD'» is git's answer to a repository with no
569
+ // commits in it, and reading it as «not a git repository» produced the one
570
+ // sentence in the feed that was flatly untrue — on a folder the person had
571
+ // just created and bound. An unborn HEAD does not reach here at all any more
572
+ // (`buildIndex` starts from the empty tree), and if some other unborn-HEAD
573
+ // read ever does, «git refused» is the honest bucket for it, not «not a repo».
574
+ if (/not a git repository/i.test(detail)) {
553
575
  return { created: false, reason: 'not-a-repo', detail };
554
576
  }
555
577
  log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
@@ -573,6 +595,10 @@ async function takeCheckpoint(store, input) {
573
595
  const meta = {
574
596
  kind,
575
597
  headSha,
598
+ // Only when it is true: an absent key is what every record written before
599
+ // this release carries, and those were all taken on a repository with a
600
+ // HEAD.
601
+ ...(headSha ? {} : { unborn: true }),
576
602
  stagedPaths,
577
603
  createdAt: Date.now(),
578
604
  fileCount: included.length,
@@ -721,10 +747,35 @@ async function buildPreview(store, indexFile, input) {
721
747
  const total = restore.length + remove.length + recreate.length;
722
748
  let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
723
749
  const commitsSince = [];
724
- if (!blockedReason && record.headSha && record.headSha !== headSha) {
750
+ // #137: an empty string is «there was no commit at all when this point was
751
+ // taken» — a real state now that a repository with no commits can hold a
752
+ // session. It must still count as movement: the first commit arriving between
753
+ // the point and the rewind moves HEAD exactly as any later one does, and the
754
+ // old `record.headSha &&` guard read that case as «HEAD has not moved» and
755
+ // let the rewind run without a word.
756
+ if (!blockedReason && record.headSha !== headSha) {
725
757
  blockedReason = 'head-moved';
726
758
  try {
727
- const listed = await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', `${record.headSha}..${headSha}`);
759
+ // A range needs two ends. With no commit at all behind the point, «what
760
+ // arrived since» is the whole history that exists — the first commit and
761
+ // whatever followed it — so the range collapses to one end. An empty
762
+ // left side would NOT do that: `..<sha>` means `HEAD..<sha>` to git,
763
+ // which is a different question and usually an empty answer.
764
+ //
765
+ // The one-ended form is used ONLY for a point that recorded «there was
766
+ // nothing here yet». A record whose metadata could not be read carries
767
+ // the same empty `headSha` and means something else entirely — listing
768
+ // the repository's whole history under «what arrived since this point»
769
+ // would be a confident, wrong answer. That record still refuses the
770
+ // rewind, which is the safe direction; it simply names no commits.
771
+ const range = record.headSha
772
+ ? `${record.headSha}..${headSha}`
773
+ : record.unborn
774
+ ? headSha
775
+ : null;
776
+ const listed = headSha && range
777
+ ? await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', range)
778
+ : '';
728
779
  for (const line of listed.split('\n')) {
729
780
  if (!line.trim())
730
781
  continue;
@@ -20,6 +20,21 @@ export interface RunnerIdentity {
20
20
  isRoot: boolean;
21
21
  }
22
22
  export declare function runnerIdentity(): RunnerIdentity;
23
+ /**
24
+ * The environment git must be run in, with the caller's own repository scrubbed
25
+ * out of it.
26
+ *
27
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
28
+ * retargets every git command at a DIFFERENT repository — the runner is started
29
+ * by systemd, but a session's agent is not, and neither is a test. The restore
30
+ * points have run this way since #126; #417 needs the same guarantee for a much
31
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
32
+ * else entirely and reports success.
33
+ *
34
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
35
+ * the answer to «how does this package run git» and has two callers.
36
+ */
37
+ export declare function cleanGitEnv(): NodeJS.ProcessEnv;
23
38
  export interface PathAccess {
24
39
  path: string;
25
40
  exists: boolean;
@@ -19,6 +19,29 @@ export function runnerIdentity() {
19
19
  }
20
20
  return { user, uid, gid, home: os.homedir(), isRoot: uid === 0 };
21
21
  }
22
+ /**
23
+ * The environment git must be run in, with the caller's own repository scrubbed
24
+ * out of it.
25
+ *
26
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
27
+ * retargets every git command at a DIFFERENT repository — the runner is started
28
+ * by systemd, but a session's agent is not, and neither is a test. The restore
29
+ * points have run this way since #126; #417 needs the same guarantee for a much
30
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
31
+ * else entirely and reports success.
32
+ *
33
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
34
+ * the answer to «how does this package run git» and has two callers.
35
+ */
36
+ export function cleanGitEnv() {
37
+ const env = { ...process.env };
38
+ delete env['GIT_DIR'];
39
+ delete env['GIT_WORK_TREE'];
40
+ delete env['GIT_INDEX_FILE'];
41
+ delete env['GIT_OBJECT_DIRECTORY'];
42
+ delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
43
+ return env;
44
+ }
22
45
  /** As root every access check passes, which is true and worth saying out loud. */
23
46
  function canAccess(target, mode) {
24
47
  try {
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;
@@ -50,6 +106,42 @@ export interface PathValidation {
50
106
  * '/opt/ids'». That sentence is true and unactionable.
51
107
  */
52
108
  export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
109
+ /**
110
+ * What became of «create this folder and put git in it» (#417).
111
+ *
112
+ * `created` and `exists` are separate answers on purpose: a folder that was
113
+ * already there is not a failure — the wizard simply goes on and binds it —
114
+ * while `created: false, exists: false` is never a success.
115
+ */
116
+ export interface ProjectDirInit {
117
+ ok: boolean;
118
+ /** This call made the directory. */
119
+ created: boolean;
120
+ /** There was already a directory at this path when we looked. */
121
+ exists: boolean;
122
+ /** The branch the new repository is on, read back rather than assumed. */
123
+ branch?: string;
124
+ error?: string;
125
+ }
126
+ /**
127
+ * Create the project folder and initialise git in it, from the binding window
128
+ * (#417).
129
+ *
130
+ * Two things and no more: ONE directory — the last segment of the path, never a
131
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
132
+ * an empty repository is a legitimate state (#137, and the runner has known how
133
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
134
+ * the thing that makes `git pull` from an existing remote refuse with
135
+ * «unrelated histories» later on.
136
+ *
137
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
138
+ * function asks «can the runner work here», which is a question about
139
+ * permissions, and every answer it gives is about reaching, reading and writing.
140
+ * «Should anything be created here at all» is a different question and this is
141
+ * the only place that asks it. The API cannot ask it either — it sees the
142
+ * runner's verdict and nothing of the machine — so the list lives here.
143
+ */
144
+ export declare function initProjectDir(target: string): Promise<ProjectDirInit>;
53
145
  /**
54
146
  * The repository's main branch, read locally (ADR 0004).
55
147
  *
package/dist/git.js CHANGED
@@ -2,8 +2,10 @@ 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 { previewsDir, worktreesDir } from './paths.js';
6
- import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
5
+ import { previewsDir, stateDir, worktreesDir } from './paths.js';
6
+ import { cleanGitEnv, firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
7
+ import { isGitInternalPath, isInsideWorktree, isSecretPath } from './policy.js';
8
+ import { log } from './log.js';
7
9
  const execFileAsync = promisify(execFile);
8
10
  const GIT_TIMEOUT_MS = 30_000;
9
11
  /**
@@ -14,12 +16,27 @@ const GIT_TIMEOUT_MS = 30_000;
14
16
  * Ninety seconds is the owner's number (plan §9, 06.09.2026).
15
17
  */
16
18
  const WORKTREE_ADD_TIMEOUT_MS = 90_000;
19
+ /**
20
+ * Every git call in this file names its repository with `cwd` and nothing else,
21
+ * so an inherited `GIT_DIR` (or index, or object store) can only ever point
22
+ * somewhere we did not mean — see `cleanGitEnv`. Free everywhere; load-bearing
23
+ * in `initProjectDir`, where `git init` under an inherited `GIT_DIR` would
24
+ * initialise a different directory and report success (#417).
25
+ */
17
26
  async function git(cwd, ...args) {
18
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
27
+ const { stdout } = await execFileAsync('git', args, {
28
+ cwd,
29
+ timeout: GIT_TIMEOUT_MS,
30
+ env: cleanGitEnv(),
31
+ });
19
32
  return stdout.trim();
20
33
  }
21
34
  async function gitSlow(cwd, timeoutMs, ...args) {
22
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: timeoutMs });
35
+ const { stdout } = await execFileAsync('git', args, {
36
+ cwd,
37
+ timeout: timeoutMs,
38
+ env: cleanGitEnv(),
39
+ });
23
40
  return stdout.trim();
24
41
  }
25
42
  /**
@@ -94,6 +111,44 @@ export class WorktreePrepareError extends Error {
94
111
  this.baseSha = base.baseSha;
95
112
  }
96
113
  }
114
+ /**
115
+ * A repository with no commits cannot hand out a copy of itself (#137).
116
+ *
117
+ * The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
118
+ * package cannot import — it is published to npm on its own, and the import
119
+ * would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
120
+ * Same arrangement as the level-event constants and the session limits: the
121
+ * text lives twice and a test pins the two copies together, because the API
122
+ * refuses this case before a session exists and the runner refuses it again if
123
+ * one ever gets that far — and the person must read one sentence, not two.
124
+ *
125
+ * `git.test.ts` compares this against the shared copy byte for byte.
126
+ */
127
+ // #region no-commits-yet-mirror
128
+ 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';
129
+ export async function headState(workspacePath) {
130
+ const [symbolic, commit] = await Promise.all([
131
+ git(workspacePath, 'symbolic-ref', '--short', 'HEAD').catch(() => null),
132
+ // `--verify --quiet` is the whole point: an unborn HEAD is exit 1 and
133
+ // SILENCE here, where the plain form is exit 128 and three lines of git.
134
+ //
135
+ // And the exit code is read rather than caught away, the same way the
136
+ // branch probe in `looksHalfCreated` reads it and for the same reason:
137
+ // exit 1 is «there is no commit», while 128, a signal or a
138
+ // dubious-ownership fatal is git failing to LOOK. `unborn` authorises
139
+ // real things — it is what offers the project folder for work and what
140
+ // makes a restore point start from the empty tree — so «could not look»
141
+ // must never arrive here dressed as «no commits yet».
142
+ git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => ({ sha, absent: false }), (error) => ({
143
+ sha: null,
144
+ absent: error.code === 1,
145
+ })),
146
+ ]);
147
+ const branch = symbolic ? symbolic.trim() : '';
148
+ if (!branch)
149
+ return { branch: null, unborn: false, detached: Boolean(commit.sha) };
150
+ return { branch, unborn: commit.absent, detached: false };
151
+ }
97
152
  /**
98
153
  * Can this runner actually work in this directory — as the user it runs as?
99
154
  *
@@ -190,20 +245,335 @@ export async function validateWorkspacePath(workspacePath) {
190
245
  `Give that user write access to the repository (for example \`chown -R ${me.user} ${workspacePath}\`), then try again.`,
191
246
  };
192
247
  }
193
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
248
+ // #137: read through `headState`, not `rev-parse --abbrev-ref HEAD`. The old
249
+ // call was the LAST thing this function did and the only one of six branch
250
+ // reads in this file left uncaught — so a directory with `git init` and no
251
+ // commit in it failed binding with git's manual page forwarded to a browser,
252
+ // after every real check above had already passed.
253
+ const head = await headState(workspacePath);
194
254
  // Binding is the one moment we are guaranteed to be looking at this repository
195
255
  // with somebody waiting for the answer, so it is where the main branch is
196
256
  // learned (ADR 0004). `branch` above is «what the folder is on right now» — a
197
257
  // drifting value, and the reason #361 exists; these two must not be confused.
258
+ //
259
+ // On a repository with no commits there is no branch to learn: nothing has
260
+ // been pushed anywhere and no conventional branch exists yet, so the binding's
261
+ // `main_branch` stays «not known yet» and learns itself later (ADR 0004).
198
262
  const defaultBranch = await remoteDefaultBranch(workspacePath);
199
263
  return {
200
264
  ok: true,
201
265
  exists: true,
202
266
  isGitRepo: true,
203
- branch,
267
+ ...(head.branch ? { branch: head.branch } : {}),
268
+ ...(head.unborn ? { unborn: true } : {}),
204
269
  ...(defaultBranch ? { defaultBranch } : {}),
205
270
  };
206
271
  }
272
+ /**
273
+ * The branch a repository created from the binding window starts on (#417).
274
+ *
275
+ * One name, here, because two calls ask for it — `git init --initial-branch`
276
+ * and the `symbolic-ref` that stands in for it on a git older than 2.28 — and a
277
+ * folder that came out on `master` because the two disagreed would be a bug
278
+ * nobody sees until a push.
279
+ */
280
+ const INITIAL_BRANCH = 'main';
281
+ /**
282
+ * Directories a project folder is never created inside (#417).
283
+ *
284
+ * The root itself and the trees a Linux system is assembled from. Deliberately
285
+ * a short, literal list rather than a clever rule: everything here is a place
286
+ * where `mkdir` succeeding is worse than it failing, and a person who typed one
287
+ * of them meant something else. `/opt`, `/srv`, `/home`, `/var/www` are NOT on
288
+ * it — those are exactly where projects live.
289
+ *
290
+ * `/usr/local` and friends are covered by their parents: the test is «is this
291
+ * path at or below one of these».
292
+ */
293
+ const SYSTEM_DIRECTORIES = [
294
+ '/etc',
295
+ '/usr',
296
+ '/bin',
297
+ '/sbin',
298
+ '/lib',
299
+ '/lib32',
300
+ '/lib64',
301
+ '/libx32',
302
+ '/boot',
303
+ '/proc',
304
+ '/sys',
305
+ '/dev',
306
+ '/run',
307
+ ];
308
+ function refuseInit(error) {
309
+ return { ok: false, created: false, exists: false, error };
310
+ }
311
+ /**
312
+ * Why this path may not be created, or `null` — asked of a STRING (#417).
313
+ *
314
+ * Pulled out of `initProjectDir` because it has to be asked twice: once about
315
+ * what the person typed, and again about where that path really lands once the
316
+ * filesystem has had its say. See `realLandingPlace`.
317
+ */
318
+ function pathClassRefusal(candidate) {
319
+ if (isGitInternalPath(candidate)) {
320
+ return `${candidate} is inside a .git directory, which belongs to git itself — pick a folder for the project instead`;
321
+ }
322
+ if (candidate === path.sep || SYSTEM_DIRECTORIES.some((dir) => isAtOrBelow(candidate, dir))) {
323
+ return `${candidate} is a system directory on this server — pick a folder for the project instead`;
324
+ }
325
+ // Said separately from the line above, because it would not be true there:
326
+ // `~/.ssh` is not a system directory, and a refusal that misnames what it is
327
+ // refusing teaches the person the wrong thing about their own machine.
328
+ if (isSecretPath(candidate)) {
329
+ return `${candidate} is on this server's protected list — pick a folder for the project instead`;
330
+ }
331
+ // `isSecretPath` cannot answer this one: the runner tree is on its list, but
332
+ // `worktrees/` and `previews/` are excused from it so an agent can read its
333
+ // own workspace. Everything under the state directory is the runner's own
334
+ // bookkeeping either way, and a project living inside it would be rewritten by
335
+ // the next session cleanup. Both sides go through the real filesystem, because
336
+ // `stateDir()` is built from `os.homedir()` and a symlinked home would
337
+ // otherwise make the two paths look unrelated.
338
+ const state = realPathOf(stateDir());
339
+ if (isAtOrBelow(candidate, state)) {
340
+ return `${candidate} is inside the runner's own state directory — pick a folder for the project instead`;
341
+ }
342
+ return null;
343
+ }
344
+ /** `realpathSync` where it works, the path itself where it does not. */
345
+ function realPathOf(target) {
346
+ try {
347
+ return fs.realpathSync(target);
348
+ }
349
+ catch {
350
+ return path.resolve(target);
351
+ }
352
+ }
353
+ /**
354
+ * Where `mkdir` would actually land, symlinks and all (#417).
355
+ *
356
+ * Every refusal above is string work — `path.resolve` does not follow symlinks,
357
+ * `isSecretPath` normalises nothing, and containment is `path.relative`. The
358
+ * kernel disagrees: on a stock Debian `/var/run` IS `/run`, so `/var/run/shop`
359
+ * passes a list that exists to refuse exactly that, and the repository ends up
360
+ * on tmpfs and is gone at the next reboot.
361
+ *
362
+ * `policy.ts` solves the same problem in `normalize()` by resolving the parent
363
+ * and putting the last segment back on. That is not enough here, because this
364
+ * is asked BEFORE anything about the parent is known: with `/tmp/link -> /etc`
365
+ * and the path `/tmp/link/new/project`, the parent does not exist, its
366
+ * `realpath` fails, and the fallback is the unresolved string — which would then
367
+ * earn the advice «create /tmp/link/new first», i.e. make a folder in `/etc`. So
368
+ * the walk goes up to the deepest ancestor that DOES exist, resolves that, and
369
+ * puts every missing segment back on.
370
+ */
371
+ function realLandingPlace(resolved) {
372
+ const missing = [];
373
+ let existing = resolved;
374
+ for (;;) {
375
+ try {
376
+ const real = fs.realpathSync(existing);
377
+ return path.join(real, ...missing.reverse());
378
+ }
379
+ catch {
380
+ const up = path.dirname(existing);
381
+ if (up === existing)
382
+ return resolved;
383
+ missing.push(path.basename(existing));
384
+ existing = up;
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Is `target` at or below `root`?
390
+ *
391
+ * `isInsideWorktree` is the containment helper this package already has, and it
392
+ * answers exactly this question — the name says worktree because that was its
393
+ * first caller, not because it knows anything about worktrees.
394
+ */
395
+ function isAtOrBelow(target, root) {
396
+ return isInsideWorktree(target, root);
397
+ }
398
+ /**
399
+ * Create the project folder and initialise git in it, from the binding window
400
+ * (#417).
401
+ *
402
+ * Two things and no more: ONE directory — the last segment of the path, never a
403
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
404
+ * an empty repository is a legitimate state (#137, and the runner has known how
405
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
406
+ * the thing that makes `git pull` from an existing remote refuse with
407
+ * «unrelated histories» later on.
408
+ *
409
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
410
+ * function asks «can the runner work here», which is a question about
411
+ * permissions, and every answer it gives is about reaching, reading and writing.
412
+ * «Should anything be created here at all» is a different question and this is
413
+ * the only place that asks it. The API cannot ask it either — it sees the
414
+ * runner's verdict and nothing of the machine — so the list lives here.
415
+ */
416
+ export async function initProjectDir(target) {
417
+ const me = runnerIdentity();
418
+ // The same rule the API's `WorkspacePathSchema` applies, re-derived here: the
419
+ // runner is the process that actually calls `mkdir`, and it must not trust the
420
+ // wire (QA-99 MAJOR-4 is the same argument for `validate_path`).
421
+ if (!target.startsWith('/') || target.split('/').includes('..')) {
422
+ return refuseInit('The project folder must be an absolute path with no ".." segments');
423
+ }
424
+ const resolved = path.resolve(target);
425
+ // The cheap pass, on what the person typed: nothing touches the disk for a
426
+ // path that is obviously not a project folder.
427
+ const typedRefusal = pathClassRefusal(resolved);
428
+ if (typedRefusal)
429
+ return refuseInit(typedRefusal);
430
+ // The second pass, and the one that decides: where would `mkdir` LAND? A
431
+ // symlinked ancestor otherwise walks straight through every refusal above,
432
+ // and `/var/run/shop` on a stock Debian is not a hypothetical: that directory
433
+ // IS `/run`, and the repository would sit on tmpfs until the next reboot.
434
+ //
435
+ // Asked BEFORE any permission check, and that order is the point (the 0.64.0
436
+ // build had it the other way round and was never published): for a runner that is
437
+ // not root, `/tmp/link -> /etc` failed «can the runner write to the parent»
438
+ // first — and the refusal then advised `chown <runner> /tmp/link`, which is
439
+ // `chown` on `/etc`. «This is a system directory» must win over «grant
440
+ // yourself access to it».
441
+ const landing = realLandingPlace(resolved);
442
+ if (landing !== resolved) {
443
+ const realRefusal = pathClassRefusal(landing);
444
+ // Both paths named, in that order: the person recognises what they typed,
445
+ // and then learns where it actually goes — which is the fact they have to
446
+ // act on.
447
+ if (realRefusal)
448
+ return refuseInit(`${resolved} leads to ${realRefusal}`);
449
+ }
450
+ const here = inspectPath(resolved);
451
+ if (here.unreachable) {
452
+ const blocked = firstUnreachableAncestor(resolved) ?? resolved;
453
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
454
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
455
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
456
+ }
457
+ if (here.exists) {
458
+ // Not a refusal, and deliberately not a `mkdir` either: a directory that is
459
+ // already there is the ordinary case of pressing the button twice, and the
460
+ // window's next move — bind it — is the right one.
461
+ if (here.isDirectory)
462
+ return { ok: true, created: false, exists: true };
463
+ return refuseInit(`${resolved} already exists and is not a directory`);
464
+ }
465
+ const parent = path.dirname(resolved);
466
+ const above = inspectPath(parent);
467
+ if (above.unreachable) {
468
+ const blocked = firstUnreachableAncestor(parent) ?? parent;
469
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
470
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
471
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
472
+ }
473
+ if (!above.exists || !above.isDirectory) {
474
+ return refuseInit(`${parent} does not exist on this server, and the runner creates only the last folder of the path. ` +
475
+ `Create ${parent} there first (\`mkdir -p ${parent}\` as a user who may write to it), then try again.`);
476
+ }
477
+ if (!above.writable) {
478
+ return refuseInit(`The runner runs as ${me.user} and cannot write to ${parent}, so it cannot create ${resolved} there. ` +
479
+ `Give that user write access (for example \`setfacl -m u:${me.user}:rwx ${parent}\`, ` +
480
+ `or \`chown ${me.user} ${parent}\` if the directory is meant to be theirs), then try again.`);
481
+ }
482
+ try {
483
+ // Deliberately NOT `recursive: true`: the chain of parents is somebody
484
+ // else's decision about the layout of their machine, and a button that
485
+ // quietly makes `/opt/a/b/c` out of a typo is worse than one that says the
486
+ // folder above is missing.
487
+ fs.mkdirSync(resolved);
488
+ }
489
+ catch (error) {
490
+ const code = error.code;
491
+ if (code === 'EEXIST') {
492
+ // Somebody else got there between `inspectPath` and here — or the name was
493
+ // never free at all: `mkdir(2)` answers EEXIST for ANY existing name, and
494
+ // a dangling symlink is invisible to the `statSync` above, which follows
495
+ // it and reports «not there». Look again rather than assume: «a directory
496
+ // is there» is what the window binds on next.
497
+ const now = inspectPath(resolved);
498
+ if (now.exists && now.isDirectory)
499
+ return { ok: true, created: false, exists: true };
500
+ return refuseInit(`${resolved} already exists and is not a directory`);
501
+ }
502
+ return refuseInit(`The runner runs as ${me.user} and could not create ${resolved} (${code ?? 'unknown error'}). ` +
503
+ `Check that ${parent} is writable by that user, then try again.`);
504
+ }
505
+ try {
506
+ await initRepositoryOnMain(resolved);
507
+ }
508
+ catch (error) {
509
+ // All-or-nothing, like laying out a worktree (#360): a folder that exists
510
+ // but is not a repository binds and then fails on «Not a git work tree»,
511
+ // which is a worse place to learn about it. `rmdirSync` refuses a directory
512
+ // with anything in it, which is exactly the safety wanted here — we are only
513
+ // ever removing the empty one made three lines up.
514
+ let takenBack = true;
515
+ try {
516
+ fs.rmdirSync(resolved);
517
+ }
518
+ catch {
519
+ // It is not empty, so it is not only ours to remove. Leave it — and say
520
+ // so, because the two outcomes want opposite things from the person.
521
+ takenBack = false;
522
+ }
523
+ // Git's own words go to the runner's log, where the person who can act on
524
+ // them is; the sentence that travels names the folder and what to do next.
525
+ // `describeGitFailure` would have carried `fatal: …` into a browser, and
526
+ // the API's net for that (`actionableRunnerRefusal`) rewrites the WHOLE
527
+ // text — so forwarding it would have cost this sentence as well.
528
+ log.warn('init_project_dir: git init failed', {
529
+ path: resolved,
530
+ takenBack,
531
+ error: describeGitFailure(error),
532
+ });
533
+ const said = takenBack
534
+ ? `Git would not start in ${resolved}, so the folder was removed again and nothing was left behind. ` +
535
+ `The runner's log on this server has git's own words — check that git is installed and that the disk is writable, then try again.`
536
+ : `${resolved} was created, but git would not start in it, and the folder is still there. ` +
537
+ `The runner's log on this server has git's own words. Run \`git init -b main\` in that folder yourself, or remove it and try again.`;
538
+ return { ok: false, created: !takenBack, exists: !takenBack, error: said };
539
+ }
540
+ // Read back rather than reported from intent: on a git old enough to need the
541
+ // fallback below, «main» is something we asked for, not something we saw.
542
+ const head = await headState(resolved);
543
+ return {
544
+ ok: true,
545
+ created: true,
546
+ exists: true,
547
+ ...(head.branch ? { branch: head.branch } : {}),
548
+ };
549
+ }
550
+ /**
551
+ * `git init` with `main` as the initial branch, on every git the fleet has.
552
+ *
553
+ * `--initial-branch` arrived in git 2.28 (2020); Debian 10 and CentOS 7 ship
554
+ * older ones and are still out there. The fallback is not a lesser outcome —
555
+ * `symbolic-ref` on a repository with no commits in it is exactly what `-b`
556
+ * does — it just takes two calls.
557
+ *
558
+ * Tried rather than version-parsed on purpose: the question is whether THIS git
559
+ * accepts the flag, and the flag itself answers it in one cheap call.
560
+ */
561
+ async function initRepositoryOnMain(repoPath) {
562
+ try {
563
+ await git(repoPath, 'init', '--quiet', '--initial-branch', INITIAL_BRANCH);
564
+ return;
565
+ }
566
+ catch {
567
+ // Falls through: either the flag is unknown, or `git init` itself failed —
568
+ // and the plain form below tells the two apart by either working or
569
+ // throwing the real reason to the caller.
570
+ }
571
+ await git(repoPath, 'init', '--quiet');
572
+ // Best effort, and deliberately not fatal: `git init` has already made a
573
+ // working repository, and the caller reads the branch back off it rather than
574
+ // trusting this call. Throwing here would throw away a folder that is fine.
575
+ await git(repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${INITIAL_BRANCH}`).catch(() => undefined);
576
+ }
207
577
  /**
208
578
  * The repository's main branch, read locally (ADR 0004).
209
579
  *
@@ -308,6 +678,18 @@ export function sanitizeBranch(hint) {
308
678
  * in the workspace repo, so the work survives worktree cleanup.
309
679
  */
310
680
  export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
681
+ // #137: a repository with no commits cannot have a copy of itself, and this
682
+ // is the only honest place to say so. Git refuses both halves of the
683
+ // arrangement — `worktree add -b x main|HEAD` is «invalid reference», and
684
+ // `merge --squash` into an unborn HEAD is «Squash commit into empty head not
685
+ // supported yet» — so a session started this way would be created, would
686
+ // fail here in git's own words, and, had it survived, could never be applied.
687
+ // The plan's decision (S3a) is to refuse early rather than to make the copy
688
+ // work: nobody makes the person's first commit for them.
689
+ const head = await headState(workspacePath);
690
+ if (head.unborn) {
691
+ throw new Error(NO_COMMITS_YET_MESSAGE);
692
+ }
311
693
  const short = sessionShortId(sessionId);
312
694
  // A ticket group gets a branch named after its tickets so the work reads as
313
695
  // one thing in git history. The worktree DIRECTORY stays id-derived — it is
@@ -743,16 +1125,27 @@ export async function prepareDirectWorkspace(workspacePath) {
743
1125
  if (inside !== 'true') {
744
1126
  throw new Error(`${workspacePath} is not a git work tree`);
745
1127
  }
746
- const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
747
- if (!branch || branch === 'HEAD') {
1128
+ // #137: `rev-parse --abbrev-ref HEAD` answered the literal word `HEAD` for a
1129
+ // detached checkout AND threw for a repository with no commits, so caught it
1130
+ // collapsed both into the detached refusal below — and a folder sitting
1131
+ // perfectly well on `main`, one `git init` old, was told to «check a branch
1132
+ // out there», which it already had. `headState` tells the two apart.
1133
+ const head = await headState(workspacePath);
1134
+ if (!head.branch) {
748
1135
  throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
749
1136
  }
750
1137
  const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
751
- const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
1138
+ // No commits yet means no fork point, and that is not a failure: DIRECT mode
1139
+ // forks from nothing anyway — the session's workplace IS this folder and this
1140
+ // branch. The field simply stays absent, which is what it already did for
1141
+ // every other reason a base sha could be missing.
1142
+ const baseSha = head.unborn
1143
+ ? undefined
1144
+ : await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
752
1145
  return {
753
- branch,
1146
+ branch: head.branch,
754
1147
  worktreePath: top || workspacePath,
755
- baseBranch: branch,
1148
+ baseBranch: head.branch,
756
1149
  ...(baseSha ? { baseSha } : {}),
757
1150
  };
758
1151
  }
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
  *
@@ -553,6 +566,11 @@ function runnerCapabilities(apiUrlOverride) {
553
566
  // это чтение, а не установка, и машину, чей владелец запретил ставить из
554
567
  // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
555
568
  'agent_versions_refresh',
569
+ // #417: create the project folder and `git init` in it, from the
570
+ // binding window. Announced here and nowhere else — the API refuses the
571
+ // button on a machine that does not name this command, because an older
572
+ // runner drops a command it cannot parse and never answers.
573
+ 'init_project_dir',
556
574
  // #396. Listed unconditionally, unlike the `restart` flag above: the
557
575
  // flag is what the dashboard draws a button from, and this list is what
558
576
  // `runCommand` dispatches on. A machine that cannot restart still
@@ -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;
@@ -9,7 +9,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
9
9
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
10
10
  import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
11
11
  import { JournalStore } from './journal.js';
12
- import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
12
+ import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, initProjectDir, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
13
13
  import { readRecipeProposal } from './recipe.js';
14
14
  import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
15
15
  import { proposeCommitMessage } from './commit-message.js';
@@ -6140,6 +6140,37 @@ export class Supervisor {
6140
6140
  ...(validation.ok ? {} : { error: validation.error }),
6141
6141
  });
6142
6142
  }
6143
+ /**
6144
+ * «Create this folder and put git in it», from the binding window
6145
+ * (#417).
6146
+ *
6147
+ * Next to `validate_path` because it is the same conversation: that
6148
+ * command answers «the folder is not there», and this one is what the
6149
+ * person presses next. Everything it is allowed and not allowed to do
6150
+ * is decided in `initProjectDir` — the API sees a verdict and never the
6151
+ * machine.
6152
+ *
6153
+ * Under the repository lock like every other operation on a folder,
6154
+ * even though there is no repository yet: `repoKeyFor` falls back to the
6155
+ * path itself when git cannot answer, so two presses of the button
6156
+ * serialise against each other rather than racing into one `mkdir`.
6157
+ */
6158
+ case 'init_project_dir': {
6159
+ const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
6160
+ if (!path)
6161
+ return void reply({ ok: false, error: 'path argument is required' });
6162
+ const init = await this.withRepoLockFor(path, () => initProjectDir(path));
6163
+ // Remembered for the same reason `validate_path` remembers it: this
6164
+ // is a real project directory now, and `devbridge-runner doctor`
6165
+ // checks the permissions of the ones it knows about.
6166
+ if (init.ok)
6167
+ rememberWorkspacePath(path);
6168
+ return void reply({
6169
+ ok: init.ok,
6170
+ result: init,
6171
+ ...(init.ok ? {} : { error: init.error }),
6172
+ });
6173
+ }
6143
6174
  case 'clean': {
6144
6175
  const sessionId = frame.sessionId;
6145
6176
  if (!sessionId)
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.64.1";
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.64.1';
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.64.1",
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",