@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/ops.js
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.NewBranchError = exports.MAX_UPLOAD_SIZE = exports.MAX_EDIT_SIZE = exports.OpError = void 0;
|
|
37
|
+
exports.opErrorStatus = opErrorStatus;
|
|
38
|
+
exports.createCollection = createCollection;
|
|
39
|
+
exports.createRepo = createRepo;
|
|
40
|
+
exports.forkRepo = forkRepo;
|
|
41
|
+
exports.commitFiles = commitFiles;
|
|
42
|
+
exports.commitFileChange = commitFileChange;
|
|
43
|
+
exports.previewMerge = previewMerge;
|
|
44
|
+
exports.mergeBranch = mergeBranch;
|
|
45
|
+
exports.createBranch = createBranch;
|
|
46
|
+
exports.deleteBranch = deleteBranch;
|
|
47
|
+
exports.createTag = createTag;
|
|
48
|
+
exports.deleteTag = deleteTag;
|
|
49
|
+
exports.setDefaultBranch = setDefaultBranch;
|
|
50
|
+
exports.setDescription = setDescription;
|
|
51
|
+
exports.containedIn = containedIn;
|
|
52
|
+
exports.renameRepo = renameRepo;
|
|
53
|
+
exports.renameCollection = renameCollection;
|
|
54
|
+
exports.deleteRepo = deleteRepo;
|
|
55
|
+
exports.writeFile = writeFile;
|
|
56
|
+
exports.createRepoWithReadme = createRepoWithReadme;
|
|
57
|
+
const crypto = __importStar(require("crypto"));
|
|
58
|
+
const fs = __importStar(require("fs"));
|
|
59
|
+
const os = __importStar(require("os"));
|
|
60
|
+
const path = __importStar(require("path"));
|
|
61
|
+
const atomic_1 = require("./atomic");
|
|
62
|
+
const git_1 = require("./git");
|
|
63
|
+
const perms_1 = require("./perms");
|
|
64
|
+
const vault_1 = require("./vault");
|
|
65
|
+
const pointer_1 = require("./pointer");
|
|
66
|
+
const redirects_1 = require("./redirects");
|
|
67
|
+
const layout_1 = require("./layout");
|
|
68
|
+
const scan_1 = require("./scan");
|
|
69
|
+
class OpError extends Error {
|
|
70
|
+
kind;
|
|
71
|
+
constructor(message, kind = 'invalid') {
|
|
72
|
+
super(message);
|
|
73
|
+
this.kind = kind;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.OpError = OpError;
|
|
77
|
+
/**
|
|
78
|
+
* The HTTP status a failed operation deserves.
|
|
79
|
+
*
|
|
80
|
+
* Both transports were deciding this inline, and the spellings had drifted from
|
|
81
|
+
* each other: several read `kind === 'exists' ? 409 : 400`, which is right about
|
|
82
|
+
* a name already taken and wrong about a `conflict`, so a branch that moved
|
|
83
|
+
* under an edit came back as a malformed request. Since the whole point of the
|
|
84
|
+
* kind is to tell a caller whether retrying could help, that distinction is
|
|
85
|
+
* worth keeping in one place.
|
|
86
|
+
*
|
|
87
|
+
* `nochange` is 400 here because a caller that asked for a state the vault is
|
|
88
|
+
* already in has changed nothing. The JSON API answers it as a success instead,
|
|
89
|
+
* since it can say `changed: false` in the body where a rendered page cannot;
|
|
90
|
+
* `sendOpError` settles that case before consulting this.
|
|
91
|
+
*/
|
|
92
|
+
function opErrorStatus(kind) {
|
|
93
|
+
switch (kind) {
|
|
94
|
+
case 'notfound':
|
|
95
|
+
return 404;
|
|
96
|
+
case 'exists':
|
|
97
|
+
case 'conflict':
|
|
98
|
+
return 409;
|
|
99
|
+
default:
|
|
100
|
+
return 400;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The largest single file a commit made through the server may carry. Both
|
|
105
|
+
* transports use it, so that what the browser refuses to edit is what the API
|
|
106
|
+
* refuses to write.
|
|
107
|
+
*/
|
|
108
|
+
exports.MAX_EDIT_SIZE = 1024 * 1024;
|
|
109
|
+
/**
|
|
110
|
+
* What one upload or one multi-file commit may carry in total. Big enough for
|
|
111
|
+
* the images and fixtures people add through a browser, small enough that the
|
|
112
|
+
* body can be held whole while it is parsed; anything larger belongs in a push,
|
|
113
|
+
* or in Git LFS.
|
|
114
|
+
*/
|
|
115
|
+
exports.MAX_UPLOAD_SIZE = 25 * 1024 * 1024;
|
|
116
|
+
function authorEnv(author) {
|
|
117
|
+
return {
|
|
118
|
+
...process.env,
|
|
119
|
+
GIT_AUTHOR_NAME: author.name,
|
|
120
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
121
|
+
GIT_COMMITTER_NAME: author.name,
|
|
122
|
+
GIT_COMMITTER_EMAIL: author.email,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function tmpFile(prefix) {
|
|
126
|
+
return path.join(os.tmpdir(), `${prefix}-${crypto.randomBytes(8).toString('hex')}`);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create an empty collection: a directory in the vault with no repositories in
|
|
130
|
+
* it yet. Pushing to a new path creates its collection on the way (see
|
|
131
|
+
* createRepo), so this exists for the other order, where a collection is made
|
|
132
|
+
* first and filled afterwards, by an import or a push.
|
|
133
|
+
*/
|
|
134
|
+
function createCollection(root, name) {
|
|
135
|
+
if (!(0, scan_1.isValidName)(name) || (0, scan_1.isDotName)(name))
|
|
136
|
+
throw new OpError('invalid collection name');
|
|
137
|
+
const dir = (0, layout_1.collectionDir)(root, name);
|
|
138
|
+
if (fs.existsSync(dir))
|
|
139
|
+
throw new OpError(`collection ${name} already exists`, 'exists');
|
|
140
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
141
|
+
fs.mkdirSync(path.join(dir, layout_1.REPOS_DIR));
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Refuse a name that would place a new repository on top of the directories
|
|
146
|
+
* another repository keeps beside it. Creation is the only place this is
|
|
147
|
+
* asked: a repository that already carries such a name goes on working.
|
|
148
|
+
*/
|
|
149
|
+
function checkNewRepoName(name) {
|
|
150
|
+
const suffix = (0, scan_1.reservedRepoSuffix)(name);
|
|
151
|
+
if (suffix) {
|
|
152
|
+
throw new OpError(`a repository may not be named to end in ${suffix}, which is reserved for the directories a repository keeps beside it`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Refuse a dot-prefixed collection. A repository's collection is created on
|
|
157
|
+
* the way when it does not exist, here and in fork and move alike, so the
|
|
158
|
+
* three of them are where a collection can come into being under a name
|
|
159
|
+
* nobody typed into the new-collection form. Only a repository may carry a
|
|
160
|
+
* leading dot; see isDotName in src/scan.ts.
|
|
161
|
+
*/
|
|
162
|
+
function checkNewCollectionName(collection) {
|
|
163
|
+
if ((0, scan_1.isDotName)(collection))
|
|
164
|
+
throw new OpError('a collection name may not begin with a dot');
|
|
165
|
+
}
|
|
166
|
+
async function createRepo(root, collection, name, opts = {}) {
|
|
167
|
+
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name))
|
|
168
|
+
throw new OpError('invalid collection or repository name');
|
|
169
|
+
checkNewCollectionName(collection);
|
|
170
|
+
checkNewRepoName(name);
|
|
171
|
+
fs.mkdirSync((0, layout_1.reposDir)(root, collection), { recursive: true });
|
|
172
|
+
const dir = (0, layout_1.repoPath)(root, collection, `${name}.git`);
|
|
173
|
+
await (0, git_1.execGit)(root, ['init', '--bare', '--initial-branch=main', dir]);
|
|
174
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.denyNonFastForwards', 'true']);
|
|
175
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.denyDeletes', 'true']);
|
|
176
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.maxInputSize', String(2 * 1024 * 1024 * 1024)]);
|
|
177
|
+
// Written before the repository is announced anywhere, so a repository asked
|
|
178
|
+
// for as private is never public for even a moment.
|
|
179
|
+
if (opts.private)
|
|
180
|
+
(0, perms_1.setRepoPrivate)(dir, true);
|
|
181
|
+
return new git_1.GitRepo(dir, collection, name);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Fork a repository inside the vault: a bare clone of one repository into
|
|
185
|
+
* another collection, with the parent recorded so both ends can say where the
|
|
186
|
+
* fork came from.
|
|
187
|
+
*
|
|
188
|
+
* A local clone hardlinks its objects, so a fork of a large repository costs
|
|
189
|
+
* almost nothing on disk until one side or the other gains new objects. The
|
|
190
|
+
* `origin` remote git writes points at a filesystem path, which means nothing
|
|
191
|
+
* to anyone reading the fork, so it is removed and replaced by a `mochi
|
|
192
|
+
* .forkedFrom` entry naming `<collection>/<repo>`. Nothing else comes across:
|
|
193
|
+
* issues, releases, runs, and the site belong to the repository that has
|
|
194
|
+
* them, and a fork starts with none.
|
|
195
|
+
*/
|
|
196
|
+
async function forkRepo(root, collection, name, toCollection, toName) {
|
|
197
|
+
const source = (0, scan_1.findRepo)(root, collection, name);
|
|
198
|
+
if (!source)
|
|
199
|
+
throw new OpError(`repository ${collection}/${name} not found`, 'notfound');
|
|
200
|
+
if (!(0, scan_1.isValidName)(toCollection) || !(0, scan_1.isValidName)(toName)) {
|
|
201
|
+
throw new OpError('invalid collection or repository name');
|
|
202
|
+
}
|
|
203
|
+
checkNewCollectionName(toCollection);
|
|
204
|
+
checkNewRepoName(toName);
|
|
205
|
+
if (toCollection === collection && toName === name) {
|
|
206
|
+
throw new OpError('a repository cannot be forked onto itself');
|
|
207
|
+
}
|
|
208
|
+
if ((0, scan_1.findRepo)(root, toCollection, toName)) {
|
|
209
|
+
throw new OpError(`${toCollection}/${toName} already exists`, 'exists');
|
|
210
|
+
}
|
|
211
|
+
fs.mkdirSync((0, layout_1.reposDir)(root, toCollection), { recursive: true });
|
|
212
|
+
const dir = (0, layout_1.repoPath)(root, toCollection, `${toName}.git`);
|
|
213
|
+
await (0, git_1.execGit)(root, ['clone', '--bare', source.dir, dir]);
|
|
214
|
+
await (0, git_1.execGit)(dir, ['remote', 'remove', 'origin']).catch(() => undefined);
|
|
215
|
+
await (0, git_1.execGit)(dir, ['config', 'mochi.forkedFrom', `${collection}/${name}`]);
|
|
216
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.denyNonFastForwards', 'true']);
|
|
217
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.denyDeletes', 'true']);
|
|
218
|
+
await (0, git_1.execGit)(dir, ['config', 'receive.maxInputSize', String(2 * 1024 * 1024 * 1024)]);
|
|
219
|
+
const description = fs.existsSync(path.join(source.dir, 'description'))
|
|
220
|
+
? fs.readFileSync(path.join(source.dir, 'description'), 'utf8')
|
|
221
|
+
: '';
|
|
222
|
+
if (description.trim() !== '' && !description.startsWith('Unnamed repository')) {
|
|
223
|
+
(0, atomic_1.writeFileAtomic)(path.join(dir, 'description'), description);
|
|
224
|
+
}
|
|
225
|
+
// A fork of a private repository starts private, so forking is never a way
|
|
226
|
+
// to publish what its parent was protecting. Collaborators do not come
|
|
227
|
+
// across: they belong to the parent, and the forker administers the fork.
|
|
228
|
+
if ((0, perms_1.repoIsPrivate)(source.dir))
|
|
229
|
+
(0, perms_1.setRepoPrivate)(dir, true);
|
|
230
|
+
return new git_1.GitRepo(dir, toCollection, toName);
|
|
231
|
+
}
|
|
232
|
+
async function refTip(repoDir, ref) {
|
|
233
|
+
try {
|
|
234
|
+
return (await (0, git_1.execGit)(repoDir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]))
|
|
235
|
+
.toString('utf8')
|
|
236
|
+
.trim();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function entryExists(repoDir, commit, filePath) {
|
|
243
|
+
try {
|
|
244
|
+
await (0, git_1.execGit)(repoDir, ['cat-file', '-e', `${commit}:${filePath}`]);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function entryMode(repoDir, commit, filePath) {
|
|
252
|
+
const out = (await (0, git_1.execGit)(repoDir, ['ls-tree', commit, '--', filePath])).toString('utf8');
|
|
253
|
+
const m = out.match(/^(\d{6}) blob /);
|
|
254
|
+
if (!m)
|
|
255
|
+
throw new OpError(`${filePath} is not a file at this commit`, 'notfound');
|
|
256
|
+
return m[1];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Commit several files at once, which is what an upload is. Same index dance
|
|
260
|
+
* as commitFileChange and the same optimistic guard on the branch tip; the
|
|
261
|
+
* difference is only that a batch of paths goes in before the tree is
|
|
262
|
+
* written, so an upload of twenty files is one commit and not twenty.
|
|
263
|
+
*
|
|
264
|
+
* A path that already exists is replaced and keeps its mode, so re-uploading
|
|
265
|
+
* a script does not quietly drop its executable bit.
|
|
266
|
+
*/
|
|
267
|
+
async function commitFiles(repoDir, args) {
|
|
268
|
+
const { branch, files, message, author, expectedHead } = args;
|
|
269
|
+
const removals = args.removals ?? [];
|
|
270
|
+
if (!(0, git_1.isValidRefName)(branch) || branch.startsWith('-'))
|
|
271
|
+
throw new OpError('invalid branch name');
|
|
272
|
+
if (files.length === 0 && removals.length === 0)
|
|
273
|
+
throw new OpError('no files to commit');
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
if (!(0, git_1.isValidRepoPath)(file.path) || file.path === '')
|
|
276
|
+
throw new OpError(`invalid file path: ${file.path}`);
|
|
277
|
+
}
|
|
278
|
+
for (const target of removals) {
|
|
279
|
+
if (!(0, git_1.isValidRepoPath)(target) || target === '')
|
|
280
|
+
throw new OpError(`invalid file path: ${target}`);
|
|
281
|
+
}
|
|
282
|
+
if (removals.length && expectedHead === null) {
|
|
283
|
+
throw new OpError('cannot delete a file on a branch that does not exist yet');
|
|
284
|
+
}
|
|
285
|
+
if (expectedHead !== null && !(0, git_1.isValidSha)(expectedHead))
|
|
286
|
+
throw new OpError('invalid expected commit');
|
|
287
|
+
const indexFile = tmpFile('mochi-index');
|
|
288
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
289
|
+
try {
|
|
290
|
+
let baseTree = null;
|
|
291
|
+
if (expectedHead !== null) {
|
|
292
|
+
baseTree = (await (0, git_1.execGit)(repoDir, ['rev-parse', `${expectedHead}^{tree}`])).toString('utf8').trim();
|
|
293
|
+
await (0, git_1.execGit)(repoDir, ['read-tree', expectedHead], { env });
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
await (0, git_1.execGit)(repoDir, ['read-tree', '--empty'], { env });
|
|
297
|
+
}
|
|
298
|
+
for (const file of files) {
|
|
299
|
+
const mode = expectedHead !== null && (await entryExists(repoDir, expectedHead, file.path))
|
|
300
|
+
? await entryMode(repoDir, expectedHead, file.path)
|
|
301
|
+
: '100644';
|
|
302
|
+
const contentFile = tmpFile('mochi-blob');
|
|
303
|
+
let blobSha;
|
|
304
|
+
try {
|
|
305
|
+
fs.writeFileSync(contentFile, file.content, { mode: 0o600 });
|
|
306
|
+
blobSha = (await (0, git_1.execGit)(repoDir, ['hash-object', '-w', '--', contentFile])).toString('utf8').trim();
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
fs.rmSync(contentFile, { force: true });
|
|
310
|
+
}
|
|
311
|
+
await (0, git_1.execGit)(repoDir, ['update-index', '--add', '--cacheinfo', `${mode},${blobSha},${file.path}`], { env });
|
|
312
|
+
}
|
|
313
|
+
for (const target of removals) {
|
|
314
|
+
if (!(await entryExists(repoDir, expectedHead, target))) {
|
|
315
|
+
throw new OpError(`${target} does not exist on ${branch}`, 'notfound');
|
|
316
|
+
}
|
|
317
|
+
// update-index --force-remove insists on a work tree; a zero-mode entry
|
|
318
|
+
// fed to --index-info removes a path without one.
|
|
319
|
+
await (0, git_1.execGit)(repoDir, ['update-index', '--index-info'], {
|
|
320
|
+
env,
|
|
321
|
+
input: `0 ${'0'.repeat(40)}\t${target}\n`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const newTree = (await (0, git_1.execGit)(repoDir, ['write-tree'], { env })).toString('utf8').trim();
|
|
325
|
+
if (newTree === baseTree)
|
|
326
|
+
throw new OpError('those files are already in the repository, unchanged', 'nochange');
|
|
327
|
+
const commitArgs = ['commit-tree', newTree, '-m', message];
|
|
328
|
+
if (expectedHead !== null)
|
|
329
|
+
commitArgs.push('-p', expectedHead);
|
|
330
|
+
const newCommit = (await (0, git_1.execGit)(repoDir, commitArgs, { env: authorEnv(author) })).toString('utf8').trim();
|
|
331
|
+
try {
|
|
332
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', `refs/heads/${branch}`, newCommit, expectedHead ?? '']);
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
const tip = await refTip(repoDir, `refs/heads/${branch}`);
|
|
336
|
+
if (tip !== expectedHead) {
|
|
337
|
+
throw new OpError(`branch ${branch} has moved since you loaded this page`, 'conflict');
|
|
338
|
+
}
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
return newCommit;
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
fs.rmSync(indexFile, { force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function commitFileChange(repoDir, args) {
|
|
348
|
+
const { branch, filePath, message, author, expectedHead, action } = args;
|
|
349
|
+
if (!(0, git_1.isValidRefName)(branch) || branch.startsWith('-'))
|
|
350
|
+
throw new OpError('invalid branch name');
|
|
351
|
+
if (!(0, git_1.isValidRepoPath)(filePath) || filePath === '')
|
|
352
|
+
throw new OpError('invalid file path');
|
|
353
|
+
if (expectedHead !== null && !(0, git_1.isValidSha)(expectedHead))
|
|
354
|
+
throw new OpError('invalid expected commit');
|
|
355
|
+
const toPath = action.kind === 'edit' && action.toPath && action.toPath !== filePath ? action.toPath : null;
|
|
356
|
+
if (toPath !== null && !(0, git_1.isValidRepoPath)(toPath))
|
|
357
|
+
throw new OpError('invalid file path');
|
|
358
|
+
if (expectedHead !== null) {
|
|
359
|
+
if (action.kind === 'create') {
|
|
360
|
+
if (await entryExists(repoDir, expectedHead, filePath)) {
|
|
361
|
+
throw new OpError(`${filePath} already exists on ${branch}`, 'exists');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (!(await entryExists(repoDir, expectedHead, filePath))) {
|
|
365
|
+
throw new OpError(`${filePath} does not exist on ${branch}`, 'notfound');
|
|
366
|
+
}
|
|
367
|
+
if (toPath !== null && (await entryExists(repoDir, expectedHead, toPath))) {
|
|
368
|
+
throw new OpError(`${toPath} already exists on ${branch}`, 'exists');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else if (action.kind !== 'create') {
|
|
372
|
+
throw new OpError('cannot edit or delete a file on a branch that does not exist');
|
|
373
|
+
}
|
|
374
|
+
const indexFile = tmpFile('mochi-index');
|
|
375
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
376
|
+
try {
|
|
377
|
+
let baseTree = null;
|
|
378
|
+
if (expectedHead !== null) {
|
|
379
|
+
baseTree = (await (0, git_1.execGit)(repoDir, ['rev-parse', `${expectedHead}^{tree}`])).toString('utf8').trim();
|
|
380
|
+
await (0, git_1.execGit)(repoDir, ['read-tree', expectedHead], { env });
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
await (0, git_1.execGit)(repoDir, ['read-tree', '--empty'], { env });
|
|
384
|
+
}
|
|
385
|
+
// update-index --force-remove insists on a work tree; feeding a zero-mode
|
|
386
|
+
// entry to --index-info removes a path without one.
|
|
387
|
+
const removePath = async (target) => {
|
|
388
|
+
await (0, git_1.execGit)(repoDir, ['update-index', '--index-info'], {
|
|
389
|
+
env,
|
|
390
|
+
input: `0 ${'0'.repeat(40)}\t${target}\n`,
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
if (action.kind === 'delete') {
|
|
394
|
+
await removePath(filePath);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
if (toPath !== null)
|
|
398
|
+
await removePath(filePath);
|
|
399
|
+
const mode = action.kind === 'edit' && expectedHead !== null
|
|
400
|
+
? await entryMode(repoDir, expectedHead, filePath)
|
|
401
|
+
: '100644';
|
|
402
|
+
const contentFile = tmpFile('mochi-blob');
|
|
403
|
+
let blobSha;
|
|
404
|
+
try {
|
|
405
|
+
fs.writeFileSync(contentFile, action.content, { mode: 0o600 });
|
|
406
|
+
blobSha = (await (0, git_1.execGit)(repoDir, ['hash-object', '-w', '--', contentFile])).toString('utf8').trim();
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
fs.rmSync(contentFile, { force: true });
|
|
410
|
+
}
|
|
411
|
+
await (0, git_1.execGit)(repoDir, ['update-index', '--add', '--cacheinfo', `${mode},${blobSha},${toPath ?? filePath}`], {
|
|
412
|
+
env,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const newTree = (await (0, git_1.execGit)(repoDir, ['write-tree'], { env })).toString('utf8').trim();
|
|
416
|
+
if (newTree === baseTree)
|
|
417
|
+
throw new OpError('no changes to commit', 'nochange');
|
|
418
|
+
const commitArgs = ['commit-tree', newTree, '-m', message];
|
|
419
|
+
if (expectedHead !== null)
|
|
420
|
+
commitArgs.push('-p', expectedHead);
|
|
421
|
+
const newCommit = (await (0, git_1.execGit)(repoDir, commitArgs, { env: authorEnv(author) })).toString('utf8').trim();
|
|
422
|
+
try {
|
|
423
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', `refs/heads/${branch}`, newCommit, expectedHead ?? '']);
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
const tip = await refTip(repoDir, `refs/heads/${branch}`);
|
|
427
|
+
if (tip !== expectedHead) {
|
|
428
|
+
throw new OpError(`branch ${branch} has moved since you loaded this page`, 'conflict');
|
|
429
|
+
}
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
return newCommit;
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
fs.rmSync(indexFile, { force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Work out what merging `head` into branch `base` would come to, in a bare
|
|
440
|
+
* repository and without a work tree.
|
|
441
|
+
*
|
|
442
|
+
* `git merge-tree --write-tree` computes the merged tree in the object
|
|
443
|
+
* database and names the paths that conflict. The tree it writes when the
|
|
444
|
+
* merge is clean is not thrown away: the caller commits exactly that tree, so
|
|
445
|
+
* the merge a reader was shown is the merge that happens. An unreferenced
|
|
446
|
+
* tree left behind by a preview is ordinary garbage that git collects.
|
|
447
|
+
*/
|
|
448
|
+
async function planMerge(repoDir, base, head) {
|
|
449
|
+
if (!(0, git_1.isValidRefName)(base) || base.startsWith('-'))
|
|
450
|
+
throw new OpError('invalid base branch');
|
|
451
|
+
if (!(0, git_1.isValidRefName)(head) || head.startsWith('-'))
|
|
452
|
+
throw new OpError('invalid head ref');
|
|
453
|
+
const baseSha = await refTip(repoDir, `refs/heads/${base}`);
|
|
454
|
+
if (!baseSha)
|
|
455
|
+
throw new OpError(`branch ${base} not found`, 'notfound');
|
|
456
|
+
const headSha = await refTip(repoDir, head);
|
|
457
|
+
if (!headSha)
|
|
458
|
+
throw new OpError(`ref ${head} not found`, 'notfound');
|
|
459
|
+
// Nothing to merge: the head is already in the base's history.
|
|
460
|
+
if ((await (0, git_1.execGitStatus)(repoDir, ['merge-base', '--is-ancestor', headSha, baseSha])).code === 0) {
|
|
461
|
+
return { status: 'up-to-date' };
|
|
462
|
+
}
|
|
463
|
+
// The base has not moved since the head left it, so the merged tree is the
|
|
464
|
+
// head's own. A merge commit is still made rather than fast-forwarding:
|
|
465
|
+
// that commit is the record of the merge, and a history where a proposal
|
|
466
|
+
// simply appears says nothing about why it was taken. This is what GitHub's
|
|
467
|
+
// merge button does too.
|
|
468
|
+
if ((await (0, git_1.execGitStatus)(repoDir, ['merge-base', '--is-ancestor', baseSha, headSha])).code === 0) {
|
|
469
|
+
const tree = (await (0, git_1.execGit)(repoDir, ['rev-parse', `${headSha}^{tree}`])).toString('utf8').trim();
|
|
470
|
+
return { status: 'clean', tree, fastForward: true, baseSha, headSha };
|
|
471
|
+
}
|
|
472
|
+
const merged = await (0, git_1.execGitStatus)(repoDir, ['merge-tree', '--write-tree', '--name-only', baseSha, headSha]);
|
|
473
|
+
const lines = merged.stdout.split('\n');
|
|
474
|
+
const tree = (lines[0] ?? '').trim();
|
|
475
|
+
if (merged.code === 1 && (0, git_1.isValidSha)(tree)) {
|
|
476
|
+
// The conflicting paths follow the tree, one per line, and a blank line
|
|
477
|
+
// ends them before git's own narration of the merge.
|
|
478
|
+
const paths = [];
|
|
479
|
+
for (const line of lines.slice(1)) {
|
|
480
|
+
if (line.trim() === '')
|
|
481
|
+
break;
|
|
482
|
+
paths.push(line);
|
|
483
|
+
}
|
|
484
|
+
return { status: 'conflict', paths };
|
|
485
|
+
}
|
|
486
|
+
if (merged.code !== 0 || !(0, git_1.isValidSha)(tree)) {
|
|
487
|
+
throw new OpError(merged.stderr.trim() || 'the merge could not be computed');
|
|
488
|
+
}
|
|
489
|
+
return { status: 'clean', tree, fastForward: false, baseSha, headSha };
|
|
490
|
+
}
|
|
491
|
+
/** Whether a merge would apply cleanly, without changing anything. */
|
|
492
|
+
async function previewMerge(repoDir, base, head) {
|
|
493
|
+
const plan = await planMerge(repoDir, base, head);
|
|
494
|
+
if (plan.status === 'clean') {
|
|
495
|
+
return { status: 'clean', fastForward: plan.fastForward, base: plan.baseSha, head: plan.headSha };
|
|
496
|
+
}
|
|
497
|
+
return plan;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Merge one ref into a branch and move the branch to the result.
|
|
501
|
+
*
|
|
502
|
+
* The branch moves with a guarded update-ref, so a branch that moved while
|
|
503
|
+
* the reader was deciding fails rather than losing the commit that moved it.
|
|
504
|
+
* Conflicts are reported, never committed: resolving them needs a work tree
|
|
505
|
+
* and a person, and the vault has neither.
|
|
506
|
+
*/
|
|
507
|
+
async function mergeBranch(repoDir, base, head, message, author, method = 'merge') {
|
|
508
|
+
const plan = await planMerge(repoDir, base, head);
|
|
509
|
+
if (plan.status !== 'clean')
|
|
510
|
+
return plan;
|
|
511
|
+
// A squash keeps the merged tree and drops the branch's shape: one commit
|
|
512
|
+
// on the base, with the base as its only parent, which is what makes the
|
|
513
|
+
// head's history disappear from the base and the head branch look unmerged
|
|
514
|
+
// afterwards. A merge keeps both parents, and with them the record of where
|
|
515
|
+
// the work came from.
|
|
516
|
+
const parents = method === 'squash' ? ['-p', plan.baseSha] : ['-p', plan.baseSha, '-p', plan.headSha];
|
|
517
|
+
const sha = (await (0, git_1.execGit)(repoDir, ['commit-tree', plan.tree, ...parents, '-m', message], {
|
|
518
|
+
env: authorEnv(author),
|
|
519
|
+
}))
|
|
520
|
+
.toString('utf8')
|
|
521
|
+
.trim();
|
|
522
|
+
await moveBranch(repoDir, base, sha, plan.baseSha);
|
|
523
|
+
return { status: 'merged', sha, before: plan.baseSha, fastForward: plan.fastForward };
|
|
524
|
+
}
|
|
525
|
+
/** Move a branch, refusing if it is no longer where the caller last saw it. */
|
|
526
|
+
async function moveBranch(repoDir, branch, to, from) {
|
|
527
|
+
try {
|
|
528
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', `refs/heads/${branch}`, to, from]);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
throw new OpError(`branch ${branch} has moved since this page was loaded`, 'conflict');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function createBranch(repoDir, name, fromRef) {
|
|
535
|
+
if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
|
|
536
|
+
throw new OpError('invalid branch name');
|
|
537
|
+
if (!(0, git_1.isValidRefName)(fromRef) || fromRef.startsWith('-'))
|
|
538
|
+
throw new OpError('invalid source ref');
|
|
539
|
+
await (0, git_1.execGit)(repoDir, ['check-ref-format', `refs/heads/${name}`]);
|
|
540
|
+
const sha = await refTip(repoDir, fromRef);
|
|
541
|
+
if (!sha)
|
|
542
|
+
throw new OpError(`ref ${fromRef} not found`, 'notfound');
|
|
543
|
+
try {
|
|
544
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', `refs/heads/${name}`, sha, '']);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
throw new OpError(`branch ${name} already exists`, 'exists');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function deleteBranch(repoDir, name) {
|
|
551
|
+
if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
|
|
552
|
+
throw new OpError('invalid branch name');
|
|
553
|
+
// update-ref -d bypasses receive.denyDeletes, deliberately: the receive
|
|
554
|
+
// config guards against accidental `push --delete`, while deletion here is
|
|
555
|
+
// explicit, confirmed intent.
|
|
556
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', '-d', `refs/heads/${name}`]);
|
|
557
|
+
}
|
|
558
|
+
async function createTag(repoDir, name, atRef) {
|
|
559
|
+
if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
|
|
560
|
+
throw new OpError('invalid tag name');
|
|
561
|
+
if (!(0, git_1.isValidRefName)(atRef) || atRef.startsWith('-'))
|
|
562
|
+
throw new OpError('invalid target ref');
|
|
563
|
+
await (0, git_1.execGit)(repoDir, ['check-ref-format', `refs/tags/${name}`]);
|
|
564
|
+
const sha = await refTip(repoDir, atRef);
|
|
565
|
+
if (!sha)
|
|
566
|
+
throw new OpError(`ref ${atRef} not found`, 'notfound');
|
|
567
|
+
try {
|
|
568
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', `refs/tags/${name}`, sha, '']);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
throw new OpError(`tag ${name} already exists`, 'exists');
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function deleteTag(repoDir, name) {
|
|
575
|
+
if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
|
|
576
|
+
throw new OpError('invalid tag name');
|
|
577
|
+
await (0, git_1.execGit)(repoDir, ['update-ref', '-d', `refs/tags/${name}`]);
|
|
578
|
+
}
|
|
579
|
+
async function setDefaultBranch(repoDir, branch) {
|
|
580
|
+
if (!(0, git_1.isValidRefName)(branch) || branch.startsWith('-'))
|
|
581
|
+
throw new OpError('invalid branch name');
|
|
582
|
+
const sha = await refTip(repoDir, `refs/heads/${branch}`);
|
|
583
|
+
if (!sha)
|
|
584
|
+
throw new OpError(`branch ${branch} not found`, 'notfound');
|
|
585
|
+
await (0, git_1.execGit)(repoDir, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
|
|
586
|
+
}
|
|
587
|
+
function setDescription(repoDir, text) {
|
|
588
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
589
|
+
(0, atomic_1.writeFileAtomic)(path.join(repoDir, 'description'), line === '' ? '' : line + '\n');
|
|
590
|
+
}
|
|
591
|
+
function containedIn(rootReal, target) {
|
|
592
|
+
let real;
|
|
593
|
+
try {
|
|
594
|
+
real = fs.realpathSync(target);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
return real.startsWith(rootReal + path.sep);
|
|
600
|
+
}
|
|
601
|
+
function siblingDir(root, collection, name, suffix) {
|
|
602
|
+
return (0, layout_1.repoPath)(root, collection, `${(0, scan_1.displayName)(name)}${suffix}`);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Rename a repository, or move it to another collection - the two are one
|
|
606
|
+
* operation, since both are a directory rename.
|
|
607
|
+
*
|
|
608
|
+
* Everything that belongs to the repository moves with it: the bare
|
|
609
|
+
* repository, its static site, its workflow runs, its issues, its pull
|
|
610
|
+
* requests, its releases, and its LFS objects. Leaving any of them behind
|
|
611
|
+
* would strand state that only that repository can reach, and worse, a
|
|
612
|
+
* repository later created under the old name would inherit it.
|
|
613
|
+
*
|
|
614
|
+
* The move is a sequence of renames rather than one atomic act, which is the
|
|
615
|
+
* honest limit of a filesystem-backed store. The repository itself moves
|
|
616
|
+
* first: if a later sibling fails, what is left behind is a directory beside
|
|
617
|
+
* the old name rather than a repository nobody can find.
|
|
618
|
+
*/
|
|
619
|
+
async function renameRepo(root, collection, name, toCollection, toName, ctx = {}) {
|
|
620
|
+
const repo = (0, scan_1.findRepo)(root, collection, name);
|
|
621
|
+
if (!repo)
|
|
622
|
+
throw new OpError(`repository ${collection}/${name} not found`, 'notfound');
|
|
623
|
+
if (!(0, scan_1.isValidName)(toCollection) || !(0, scan_1.isValidName)(toName)) {
|
|
624
|
+
throw new OpError('invalid collection or repository name');
|
|
625
|
+
}
|
|
626
|
+
checkNewCollectionName(toCollection);
|
|
627
|
+
checkNewRepoName(toName);
|
|
628
|
+
if (toCollection === collection && toName === name)
|
|
629
|
+
throw new OpError('that is already its name', 'nochange');
|
|
630
|
+
if ((0, scan_1.findRepo)(root, toCollection, toName)) {
|
|
631
|
+
throw new OpError(`${toCollection}/${toName} already exists`, 'exists');
|
|
632
|
+
}
|
|
633
|
+
const rootReal = fs.realpathSync(root);
|
|
634
|
+
if (!containedIn(rootReal, repo.dir)) {
|
|
635
|
+
throw new OpError('repository directory is outside the vault; refusing to move it');
|
|
636
|
+
}
|
|
637
|
+
// The .git suffix is optional on disk and is kept as it was found, so a
|
|
638
|
+
// move never changes how git-lfs derives its endpoint for this repository.
|
|
639
|
+
const suffix = path.basename(repo.dir).endsWith('.git') ? '.git' : '';
|
|
640
|
+
const destRepos = (0, layout_1.reposDir)(root, toCollection);
|
|
641
|
+
fs.mkdirSync(destRepos, { recursive: true });
|
|
642
|
+
const destRepo = path.join(destRepos, `${toName}${suffix}`);
|
|
643
|
+
if (fs.existsSync(destRepo))
|
|
644
|
+
throw new OpError(`${toCollection}/${toName} already exists`, 'exists');
|
|
645
|
+
// Every check has passed, so from here the move is going to happen: the run
|
|
646
|
+
// index is told to forget the old identity before the directories move out
|
|
647
|
+
// from under it. After the checks rather than before, so that a rename
|
|
648
|
+
// refused for a name that is taken leaves a running job's index entry alone.
|
|
649
|
+
ctx.runs?.forgetRepo(collection, name);
|
|
650
|
+
fs.renameSync(repo.dir, destRepo);
|
|
651
|
+
// The siblings, each moved only if it is there and inside the vault.
|
|
652
|
+
// containedIn resolves the path first, so a sibling that was never created
|
|
653
|
+
// fails the same check as one pointing out of the vault, and is skipped.
|
|
654
|
+
const move = (from, to) => {
|
|
655
|
+
if (!containedIn(rootReal, from))
|
|
656
|
+
return;
|
|
657
|
+
if (fs.existsSync(to)) {
|
|
658
|
+
throw new OpError(`${path.basename(to)} already exists next to ${toCollection}/${toName}`, 'exists');
|
|
659
|
+
}
|
|
660
|
+
fs.renameSync(from, to);
|
|
661
|
+
};
|
|
662
|
+
for (const suffix of scan_1.repoSiblingSuffixes) {
|
|
663
|
+
move(siblingDir(root, collection, name, suffix), path.join(destRepos, `${toName}${suffix}`));
|
|
664
|
+
}
|
|
665
|
+
// LFS objects carry the repository in their key or their path, so the store
|
|
666
|
+
// moves them itself. Unlike deletion this is not best-effort: an object left
|
|
667
|
+
// behind is one a clone of the moved repository cannot fetch.
|
|
668
|
+
if (ctx.lfs)
|
|
669
|
+
await ctx.lfs.renameRepo(collection, name, toCollection, toName);
|
|
670
|
+
// Last, and only once everything has arrived: the old address is remembered,
|
|
671
|
+
// so a clone or a link that still names it is redirected here rather than
|
|
672
|
+
// 404ing. Recorded after the moves for a reason - a redirect written first
|
|
673
|
+
// and a move that then failed would point at a repository that is not there.
|
|
674
|
+
// See src/redirects.ts for what the redirect does and does not survive.
|
|
675
|
+
(0, redirects_1.recordRepoRename)(root, collection, name, toCollection, toName);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Rename a collection.
|
|
679
|
+
*
|
|
680
|
+
* A collection holds everything of its own inside its directory, so unlike a
|
|
681
|
+
* repository move this is a single rename and not a sequence of them: the
|
|
682
|
+
* repositories, the directories each of them keeps beside it, and locally
|
|
683
|
+
* stored LFS objects all sit under the directory being moved and arrive at
|
|
684
|
+
* the new name together. Only objects in a bucket have to be dealt with
|
|
685
|
+
* separately, since their keys name the collection rather than living in it,
|
|
686
|
+
* and the store is asked to move each repository's for that reason.
|
|
687
|
+
*
|
|
688
|
+
* One thing a rename does not carry with it, and the interface says so before
|
|
689
|
+
* it is done: token scopes in vault.json still name the old collection. The
|
|
690
|
+
* old address itself is remembered, so requests for it are redirected here;
|
|
691
|
+
* see src/redirects.ts.
|
|
692
|
+
*/
|
|
693
|
+
async function renameCollection(root, name, toName, ctx = {}) {
|
|
694
|
+
if (!(0, scan_1.isValidName)(name) || !(0, scan_1.isValidName)(toName) || (0, scan_1.isDotName)(toName)) {
|
|
695
|
+
throw new OpError('invalid collection name');
|
|
696
|
+
}
|
|
697
|
+
const dir = (0, layout_1.collectionDir)(root, name);
|
|
698
|
+
let isDir = false;
|
|
699
|
+
try {
|
|
700
|
+
isDir = fs.statSync(dir).isDirectory();
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
isDir = false;
|
|
704
|
+
}
|
|
705
|
+
if (!isDir)
|
|
706
|
+
throw new OpError(`collection ${name} not found`, 'notfound');
|
|
707
|
+
if (toName === name)
|
|
708
|
+
throw new OpError('that is already its name', 'nochange');
|
|
709
|
+
const dest = (0, layout_1.collectionDir)(root, toName);
|
|
710
|
+
if (fs.existsSync(dest))
|
|
711
|
+
throw new OpError(`collection ${toName} already exists`, 'exists');
|
|
712
|
+
const rootReal = fs.realpathSync(root);
|
|
713
|
+
if (!containedIn(rootReal, dir)) {
|
|
714
|
+
throw new OpError('collection directory is outside the vault; refusing to move it');
|
|
715
|
+
}
|
|
716
|
+
// Read the repositories before the move, since afterwards they are only
|
|
717
|
+
// findable under the new name and the store still has to be told the old
|
|
718
|
+
// one.
|
|
719
|
+
const repos = (0, scan_1.listRepoDirs)(root, name).map(scan_1.displayName);
|
|
720
|
+
// As in renameRepo, and for every repository the collection holds: after the
|
|
721
|
+
// checks, before the directory moves.
|
|
722
|
+
for (const repo of repos)
|
|
723
|
+
ctx.runs?.forgetRepo(name, repo);
|
|
724
|
+
fs.renameSync(dir, dest);
|
|
725
|
+
// Objects in a bucket, as for a repository move: not best-effort, since an
|
|
726
|
+
// object left behind is one a clone of the moved repository cannot fetch.
|
|
727
|
+
// The local backend finds nothing to move, its directories having travelled
|
|
728
|
+
// with the collection already.
|
|
729
|
+
if (ctx.lfs) {
|
|
730
|
+
for (const repo of repos)
|
|
731
|
+
await ctx.lfs.renameRepo(name, repo, toName, repo);
|
|
732
|
+
}
|
|
733
|
+
// Implicit ownership follows from the collection's name, so a rename severs
|
|
734
|
+
// it: a user who renamed the collection named after them would be locked out
|
|
735
|
+
// of what is still theirs. The implicit owner, when there is such a user, is
|
|
736
|
+
// therefore written into the explicit owners the file carries across. Only
|
|
737
|
+
// ever added, never removed: the reverse rename leaves them listed, which is
|
|
738
|
+
// redundant beside the name and costs nothing.
|
|
739
|
+
const state = (0, vault_1.loadVault)(root);
|
|
740
|
+
if (state.status === 'ok' && state.vault.users[name] && name !== toName) {
|
|
741
|
+
(0, perms_1.addCollectionOwner)(root, toName, name);
|
|
742
|
+
}
|
|
743
|
+
// As for a repository: the old name is remembered, so every address under it
|
|
744
|
+
// - the collection page and every repository in it - is redirected to the new
|
|
745
|
+
// one until something else is created under that name.
|
|
746
|
+
(0, redirects_1.recordCollectionRename)(root, name, toName);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Delete a repository and everything it accumulated: the bare repository, its
|
|
750
|
+
* static site, its workflow runs, its issues, its pull requests, its
|
|
751
|
+
* releases, and its LFS objects.
|
|
752
|
+
*/
|
|
753
|
+
async function deleteRepo(root, collection, name, ctx = {}) {
|
|
754
|
+
const repo = (0, scan_1.findRepo)(root, collection, name);
|
|
755
|
+
if (!repo)
|
|
756
|
+
throw new OpError(`repository ${collection}/${name} not found`, 'notfound');
|
|
757
|
+
const rootReal = fs.realpathSync(root);
|
|
758
|
+
if (!containedIn(rootReal, repo.dir)) {
|
|
759
|
+
throw new OpError('repository directory is outside the vault; refusing to delete');
|
|
760
|
+
}
|
|
761
|
+
// As in renameRepo: after the checks, before the files go, so that nothing
|
|
762
|
+
// is dispatched for a repository that is about to stop existing.
|
|
763
|
+
ctx.runs?.forgetRepo(collection, name);
|
|
764
|
+
fs.rmSync(repo.dir, { recursive: true, force: true });
|
|
765
|
+
// The siblings go too: the site, the workflow runs, the issues, the pull
|
|
766
|
+
// requests, and the releases. Leaving any of them would orphan a history
|
|
767
|
+
// nothing can reach, and worse, a repository later created under the same
|
|
768
|
+
// name would inherit it, with run, issue, and pull numbers continuing from
|
|
769
|
+
// someone else's.
|
|
770
|
+
for (const suffix of scan_1.repoSiblingSuffixes) {
|
|
771
|
+
const dir = siblingDir(root, collection, name, suffix);
|
|
772
|
+
if (containedIn(rootReal, dir)) {
|
|
773
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// Stored LFS objects go too, best-effort: by this point the repository is
|
|
777
|
+
// gone and the objects are unreachable garbage, so a storage failure is
|
|
778
|
+
// logged rather than allowed to fail the deletion.
|
|
779
|
+
if (ctx.lfs) {
|
|
780
|
+
try {
|
|
781
|
+
await ctx.lfs.deleteRepo(collection, name);
|
|
782
|
+
}
|
|
783
|
+
catch (e) {
|
|
784
|
+
console.error(`LFS cleanup for ${collection}/${name} failed: ${e instanceof Error ? e.message : e}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// And any redirect that led here. A redirect pointing at a repository that
|
|
788
|
+
// no longer exists is inert, but one left pointing at a name that is later
|
|
789
|
+
// re-created would send traffic meant for the deleted repository to whatever
|
|
790
|
+
// took its place.
|
|
791
|
+
(0, redirects_1.forgetRepoRedirects)(root, collection, name);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* A write that failed before it began, because the branch it was to go on could
|
|
795
|
+
* not be made. Distinct from the write's own failures because it means something
|
|
796
|
+
* different to a caller: a name already taken is somebody else's branch, not a
|
|
797
|
+
* file that is already there.
|
|
798
|
+
*/
|
|
799
|
+
class NewBranchError extends OpError {
|
|
800
|
+
}
|
|
801
|
+
exports.NewBranchError = NewBranchError;
|
|
802
|
+
/** Whether a path holds a Git LFS pointer rather than the file itself. */
|
|
803
|
+
async function isPointerAt(repoDir, commit, filePath) {
|
|
804
|
+
try {
|
|
805
|
+
const buf = await (0, git_1.execGit)(repoDir, ['cat-file', 'blob', `${commit}:${filePath}`]);
|
|
806
|
+
return (0, pointer_1.looksLikePointer)(buf);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function writeFile(repoDir, request) {
|
|
813
|
+
let branch = request.branch;
|
|
814
|
+
if (request.newBranch) {
|
|
815
|
+
if (request.expectedHead === null) {
|
|
816
|
+
throw new NewBranchError('There is nothing to branch from yet; commit to this branch first.');
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
await createBranch(repoDir, request.newBranch, request.expectedHead);
|
|
820
|
+
}
|
|
821
|
+
catch (e) {
|
|
822
|
+
if (e instanceof OpError)
|
|
823
|
+
throw new NewBranchError(e.message, e.kind);
|
|
824
|
+
throw new NewBranchError('Could not create that branch.');
|
|
825
|
+
}
|
|
826
|
+
branch = request.newBranch;
|
|
827
|
+
}
|
|
828
|
+
// The repository holds a pointer, not the file, so replacing it with text
|
|
829
|
+
// would replace the pointer and leave the object orphaned. Refused rather than
|
|
830
|
+
// done, in both transports, and checked here so that neither can skip it.
|
|
831
|
+
if (request.expectedHead !== null &&
|
|
832
|
+
request.action.kind !== 'create' &&
|
|
833
|
+
(await isPointerAt(repoDir, request.expectedHead, request.filePath))) {
|
|
834
|
+
throw new OpError(`${request.filePath} is stored with Git LFS; the repository holds only a pointer to it. Change it with a git client instead.`);
|
|
835
|
+
}
|
|
836
|
+
const sha = await commitFileChange(repoDir, {
|
|
837
|
+
branch,
|
|
838
|
+
filePath: request.filePath,
|
|
839
|
+
message: request.message,
|
|
840
|
+
author: request.author,
|
|
841
|
+
expectedHead: request.expectedHead,
|
|
842
|
+
action: request.action,
|
|
843
|
+
});
|
|
844
|
+
return { branch, sha, before: request.expectedHead };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Create a repository and, when asked, put a first commit in it. The README is
|
|
848
|
+
* what makes a new repository browsable rather than a page of instructions, and
|
|
849
|
+
* both transports offer it.
|
|
850
|
+
*
|
|
851
|
+
* The caller fires the push event, since it holds the CI engine.
|
|
852
|
+
*/
|
|
853
|
+
async function createRepoWithReadme(root, collection, name, opts) {
|
|
854
|
+
const repo = await createRepo(root, collection, name, { private: opts.private });
|
|
855
|
+
const description = (opts.description ?? '').trim();
|
|
856
|
+
if (description)
|
|
857
|
+
setDescription(repo.dir, description);
|
|
858
|
+
if (!opts.readme)
|
|
859
|
+
return { repo, sha: null };
|
|
860
|
+
const sha = await commitFileChange(repo.dir, {
|
|
861
|
+
branch: 'main',
|
|
862
|
+
filePath: 'README.md',
|
|
863
|
+
message: 'Initial commit',
|
|
864
|
+
author: opts.author,
|
|
865
|
+
expectedHead: null,
|
|
866
|
+
action: { kind: 'create', content: Buffer.from(`# ${name}\n${description ? `\n${description}\n` : ''}`) },
|
|
867
|
+
});
|
|
868
|
+
return { repo, sha };
|
|
869
|
+
}
|