@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.
- package/dist/checkpoints.d.ts +11 -0
- package/dist/checkpoints.js +69 -18
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +23 -0
- package/dist/git.d.ts +92 -0
- package/dist/git.js +404 -11
- package/dist/gitops.d.ts +29 -0
- package/dist/gitops.js +74 -19
- package/dist/index.js +18 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/supervisor.js +32 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/checkpoints.d.ts
CHANGED
|
@@ -18,6 +18,17 @@ export interface CheckpointRecord {
|
|
|
18
18
|
kind: CheckpointKind;
|
|
19
19
|
/** HEAD of the worktree when the checkpoint was taken. */
|
|
20
20
|
headSha: string;
|
|
21
|
+
/**
|
|
22
|
+
* #137: there was no commit at all when this point was taken — an empty
|
|
23
|
+
* `headSha` that MEANS something.
|
|
24
|
+
*
|
|
25
|
+
* Written down rather than inferred, because the empty string is already the
|
|
26
|
+
* value `decodeMeta` invents for a record whose metadata it could not read.
|
|
27
|
+
* Those two states need opposite treatment: «nothing was committed yet» can
|
|
28
|
+
* say truthfully what arrived since (all of it), while «we do not know what
|
|
29
|
+
* HEAD was» must not claim the whole history arrived after the point.
|
|
30
|
+
*/
|
|
31
|
+
unborn?: boolean;
|
|
21
32
|
/** Paths that were staged in the project's index at that moment. */
|
|
22
33
|
stagedPaths: string[];
|
|
23
34
|
createdAt: number;
|
package/dist/checkpoints.js
CHANGED
|
@@ -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:
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/environment.d.ts
CHANGED
|
@@ -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;
|
package/dist/environment.js
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
249
|
+
// #137: this read had no `.catch` at all, so on a repository with no commits
|
|
250
|
+
// git's own manual page («ambiguous argument 'HEAD'») travelled out of the
|
|
251
|
+
// runner, through the API and into a 409 toast. Every refusal that can be
|
|
252
|
+
// reached from a screen leaves here as a sentence somebody can act on.
|
|
253
|
+
const head = await headState(workspacePath);
|
|
254
|
+
// #137: NOT the branch-picker's sentence. That one says «start a session in
|
|
255
|
+
// the project folder itself», which is advice for somebody choosing where a
|
|
256
|
+
// session will work — and whoever reads THIS is already inside one, looking
|
|
257
|
+
// at a file list or a history tab. True but unactionable is the exact shape
|
|
258
|
+
// this ticket is about, so each place says the thing its own reader can do.
|
|
259
|
+
if (head.unborn) {
|
|
260
|
+
throw new Error('This repository has no commits yet, so there is nothing to compare this session against. Make the first commit and it will start working.');
|
|
261
|
+
}
|
|
262
|
+
const current = head.branch;
|
|
263
|
+
if (current && current !== sessionBranch) {
|
|
251
264
|
return { baseBranch: current, baseRef: current };
|
|
252
265
|
}
|
|
253
266
|
// One list, one order, one place — the same helper the binding uses to learn
|
|
@@ -255,7 +268,9 @@ async function guessBase(workspacePath, sessionBranch) {
|
|
|
255
268
|
const conventional = await firstConventionalBranch(workspacePath);
|
|
256
269
|
if (conventional)
|
|
257
270
|
return { baseBranch: conventional, baseRef: conventional };
|
|
258
|
-
throw new Error(
|
|
271
|
+
throw new Error(current
|
|
272
|
+
? `Cannot determine the base branch (the project folder is on ${current}, the session's own branch)`
|
|
273
|
+
: 'Cannot determine the base branch: the project folder is not on a branch (a detached HEAD) — check one out there');
|
|
259
274
|
}
|
|
260
275
|
/** How far apart two refs are, in one call: `[behind, ahead]`. */
|
|
261
276
|
async function aheadBehind(cwd, baseRef, branchRef) {
|
|
@@ -1136,7 +1151,27 @@ async function applySessionImpl(input) {
|
|
|
1136
1151
|
error: WORKSPACE_DIRTY_MESSAGE,
|
|
1137
1152
|
};
|
|
1138
1153
|
}
|
|
1139
|
-
|
|
1154
|
+
// #137: read through `headState`. The bare call threw git's manual page into
|
|
1155
|
+
// the «Apply» toast on a repository with no commits, and answered the literal
|
|
1156
|
+
// word `HEAD` on a detached one — a value that then went on to be compared
|
|
1157
|
+
// with a branch name and merged into.
|
|
1158
|
+
const workspaceHead = await headState(workspacePath);
|
|
1159
|
+
if (workspaceHead.unborn) {
|
|
1160
|
+
// Its own sentence, for the same reason as `guessBase` above: whoever
|
|
1161
|
+
// pressed «Apply» is not choosing where to work, and telling them to start
|
|
1162
|
+
// a session in the project folder answers a question they did not ask.
|
|
1163
|
+
return {
|
|
1164
|
+
applied: false,
|
|
1165
|
+
error: 'The project folder has no commits yet, and git cannot fold work into a branch that has none. Make the first commit there, then apply.',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const workspaceBranch = workspaceHead.branch;
|
|
1169
|
+
if (!workspaceBranch) {
|
|
1170
|
+
return {
|
|
1171
|
+
applied: false,
|
|
1172
|
+
error: 'The project folder is not on a branch (a detached HEAD), so there is nowhere to apply this to — check a branch out there first',
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1140
1175
|
if (workspaceBranch === sessionBranch) {
|
|
1141
1176
|
return { applied: false, error: 'The workspace is checked out on the session branch itself' };
|
|
1142
1177
|
}
|
|
@@ -1156,6 +1191,7 @@ async function applySessionImpl(input) {
|
|
|
1156
1191
|
await git(worktreePath, 'commit', '-m', 'chore(devbridge): session changes before apply');
|
|
1157
1192
|
}
|
|
1158
1193
|
const branchSha = await git(workspacePath, 'rev-parse', sessionBranch);
|
|
1194
|
+
// Safe unconditionally now: an unborn HEAD left through the refusal above.
|
|
1159
1195
|
const baseShaBefore = await git(workspacePath, 'rev-parse', 'HEAD');
|
|
1160
1196
|
// Captured BEFORE anything is written, so a failed merge can tell its own
|
|
1161
1197
|
// leftovers from somebody's work that appeared meanwhile.
|
|
@@ -1339,10 +1375,15 @@ const MAX_BRANCHES = 200;
|
|
|
1339
1375
|
* not after the worktree command fails.
|
|
1340
1376
|
*/
|
|
1341
1377
|
export async function gitBranches(workspacePath) {
|
|
1342
|
-
const [rawBranches, rawWorktrees,
|
|
1378
|
+
const [rawBranches, rawWorktrees, head, remotesRaw] = await Promise.all([
|
|
1343
1379
|
git(workspacePath, 'for-each-ref', `--count=${MAX_BRANCHES + 1}`, '--sort=-committerdate', '--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(subject)', 'refs/heads/'),
|
|
1344
1380
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1345
|
-
|
|
1381
|
+
// #137: through `headState`, not `rev-parse --abbrev-ref HEAD`. Caught, that
|
|
1382
|
+
// call answered `null` for a folder one `git init` old and the literal word
|
|
1383
|
+
// `HEAD` for a detached one — so the wizard could not tell a repository
|
|
1384
|
+
// waiting for its first commit from a checkout nobody should commit into,
|
|
1385
|
+
// and greyed out «In the project folder» for both.
|
|
1386
|
+
headState(workspacePath),
|
|
1346
1387
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1347
1388
|
]);
|
|
1348
1389
|
// `worktree list --porcelain` emits stanzas: `worktree <path>` … `branch <ref>`.
|
|
@@ -1380,8 +1421,12 @@ export async function gitBranches(workspacePath) {
|
|
|
1380
1421
|
branch.merged = mergedSet.has(branch.name);
|
|
1381
1422
|
return {
|
|
1382
1423
|
branches,
|
|
1383
|
-
currentBranch:
|
|
1384
|
-
|
|
1424
|
+
currentBranch: head.branch,
|
|
1425
|
+
unborn: head.unborn,
|
|
1426
|
+
detached: head.detached,
|
|
1427
|
+
currentSha: head.unborn
|
|
1428
|
+
? null
|
|
1429
|
+
: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1385
1430
|
defaultBranch: await remoteDefaultBranch(workspacePath).catch(() => null),
|
|
1386
1431
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1387
1432
|
truncated,
|
|
@@ -1886,11 +1931,16 @@ const MAX_REFS = 500;
|
|
|
1886
1931
|
* short name.
|
|
1887
1932
|
*/
|
|
1888
1933
|
export async function gitRefs(workspacePath) {
|
|
1889
|
-
const [raw, rawWorktrees,
|
|
1934
|
+
const [raw, rawWorktrees, head, currentSha, remotesRaw] = await Promise.all([
|
|
1890
1935
|
git(workspacePath, 'for-each-ref', `--count=${MAX_REFS + 1}`, '--sort=-committerdate', '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)%00%(upstream:short)%00%(subject)', 'refs/heads', 'refs/remotes', 'refs/tags'),
|
|
1891
1936
|
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1892
|
-
|
|
1893
|
-
|
|
1937
|
+
// #137: the fifth reader of the same question, and the one left behind by
|
|
1938
|
+
// the first pass. Caught, so no raw git escapes — but it answered «no
|
|
1939
|
+
// branch» for a folder the wizard, the run's precondition and the session
|
|
1940
|
+
// feed all report as standing on `main`, and one screen disagreeing with
|
|
1941
|
+
// three about the same folder is how this class of bug starts again.
|
|
1942
|
+
headState(workspacePath),
|
|
1943
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1894
1944
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1895
1945
|
]);
|
|
1896
1946
|
const checkedOut = new Map();
|
|
@@ -1925,7 +1975,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1925
1975
|
refs.push({
|
|
1926
1976
|
name: name.slice(0, 200),
|
|
1927
1977
|
type,
|
|
1928
|
-
isHead: type === 'head' && name ===
|
|
1978
|
+
isHead: type === 'head' && name === head.branch,
|
|
1929
1979
|
sha,
|
|
1930
1980
|
subject: maskString(subjectParts.join('\0')).slice(0, 300),
|
|
1931
1981
|
date: (date ?? '').slice(0, 40),
|
|
@@ -1935,7 +1985,7 @@ export async function gitRefs(workspacePath) {
|
|
|
1935
1985
|
}
|
|
1936
1986
|
return {
|
|
1937
1987
|
refs,
|
|
1938
|
-
currentBranch:
|
|
1988
|
+
currentBranch: head.branch,
|
|
1939
1989
|
currentSha,
|
|
1940
1990
|
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1941
1991
|
truncated,
|
|
@@ -1949,21 +1999,26 @@ export async function gitRefs(workspacePath) {
|
|
|
1949
1999
|
* question behind «how do I know docker was not rebuilt». Read-only.
|
|
1950
2000
|
*/
|
|
1951
2001
|
export async function workspaceState(workspacePath) {
|
|
1952
|
-
const [
|
|
1953
|
-
|
|
1954
|
-
|
|
2002
|
+
const [head, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
|
|
2003
|
+
// #137: `rev-parse --abbrev-ref HEAD` could not tell «no commits yet» from
|
|
2004
|
+
// «detached HEAD» — both arrived here as `null` — and this value is what the
|
|
2005
|
+
// run's precondition reads, so a fresh `git init` was refused with a
|
|
2006
|
+
// sentence about checking a branch out that it could not act on.
|
|
2007
|
+
headState(workspacePath),
|
|
2008
|
+
git(workspacePath, 'rev-parse', '--verify', '--quiet', 'HEAD').catch(() => null),
|
|
1955
2009
|
git(workspacePath, 'status', '--porcelain').catch(() => ''),
|
|
1956
2010
|
git(workspacePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
|
|
1957
2011
|
git(workspacePath, 'remote').catch(() => ''),
|
|
1958
2012
|
git(workspacePath, 'log', '-1', '--format=%s%x00%cI', 'HEAD', '--').catch(() => ''),
|
|
1959
2013
|
]);
|
|
1960
|
-
const branch =
|
|
2014
|
+
const branch = head.branch;
|
|
1961
2015
|
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
1962
2016
|
const distance = upstream ? await aheadBehind(workspacePath, upstream, 'HEAD') : null;
|
|
1963
2017
|
const [subject, date] = meta.split('\0');
|
|
1964
2018
|
const dirtyLines = porcelain.split('\n').filter(Boolean);
|
|
1965
2019
|
return {
|
|
1966
2020
|
branch,
|
|
2021
|
+
unborn: head.unborn,
|
|
1967
2022
|
sha,
|
|
1968
2023
|
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
1969
2024
|
date: (date ?? '').slice(0, 40),
|
package/dist/index.js
CHANGED
|
@@ -393,6 +393,19 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
393
393
|
* machine in the fleet is up to date.
|
|
394
394
|
*/
|
|
395
395
|
agentOutsideFolder: true,
|
|
396
|
+
/**
|
|
397
|
+
* #137: this runner can read a repository that has no commits yet.
|
|
398
|
+
*
|
|
399
|
+
* Announced for one reason, and it is a message rather than a refusal: when
|
|
400
|
+
* an OLDER runner meets that repository it answers with git's own manual
|
|
401
|
+
* page, and the API turns anything it cannot parse into «run `git status`
|
|
402
|
+
* on that server and see». For this particular case that advice is a dead
|
|
403
|
+
* end — `git status` there prints «On branch main / No commits yet», which
|
|
404
|
+
* looks perfectly healthy, and the person is none the wiser. With this flag
|
|
405
|
+
* the API can say the true thing instead: the runner on that machine is too
|
|
406
|
+
* old for this, update it.
|
|
407
|
+
*/
|
|
408
|
+
unbornHead: true,
|
|
396
409
|
/**
|
|
397
410
|
* Session 16: git is git.
|
|
398
411
|
*
|
|
@@ -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
|
package/dist/protocol.d.ts
CHANGED
|
@@ -783,6 +783,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
783
783
|
branchPlan?: unknown;
|
|
784
784
|
}>, "many">;
|
|
785
785
|
}, "strip", z.ZodTypeAny, {
|
|
786
|
+
type: "hello_ack";
|
|
786
787
|
sessions: {
|
|
787
788
|
mode: "ask" | "plan" | "auto" | "full";
|
|
788
789
|
agent: "CLAUDE" | "CODEX";
|
|
@@ -835,11 +836,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
835
836
|
baseSha?: string | undefined;
|
|
836
837
|
} | undefined;
|
|
837
838
|
}[];
|
|
838
|
-
type: "hello_ack";
|
|
839
839
|
serverName: string;
|
|
840
840
|
serverId: string;
|
|
841
841
|
maxSessions?: number | undefined;
|
|
842
842
|
}, {
|
|
843
|
+
type: "hello_ack";
|
|
843
844
|
sessions: {
|
|
844
845
|
agent: "CLAUDE" | "CODEX";
|
|
845
846
|
status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
|
|
@@ -887,7 +888,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
887
888
|
branchHint?: unknown;
|
|
888
889
|
branchPlan?: unknown;
|
|
889
890
|
}[];
|
|
890
|
-
type: "hello_ack";
|
|
891
891
|
serverName: string;
|
|
892
892
|
serverId: string;
|
|
893
893
|
maxSessions?: unknown;
|
package/dist/supervisor.js
CHANGED
|
@@ -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.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.64.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED