@foss.global/codefeed 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +32 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +30 -6
- package/dist_ts/classes.giteasourceinventory.js +179 -23
- package/dist_ts/classes.giteasourcetransfer.d.ts +55 -3
- package/dist_ts/classes.giteasourcetransfer.js +155 -4
- package/dist_ts/classes.lfsbatchclient.js +17 -118
- package/dist_ts/forgeinventory.transfer.d.ts +75 -0
- package/dist_ts/forgeinventory.transfer.js +174 -0
- package/package.json +3 -3
- package/readme.md +16 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +188 -23
- package/ts/classes.giteasourcetransfer.ts +182 -4
- package/ts/classes.lfsbatchclient.ts +15 -98
- package/ts/forgeinventory.transfer.ts +203 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as validation from './forgeinventory.validation.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads and drops at most 64 KiB of a response body that carries nothing the
|
|
5
|
+
* caller uses, so its connection can be reused; a longer or failing body is
|
|
6
|
+
* discarded with its connection.
|
|
7
|
+
*/
|
|
8
|
+
export declare const discardBody: (bodyArg: AsyncIterable<Uint8Array> | null) => Promise<void>;
|
|
9
|
+
export interface ITransferBoundsOptions {
|
|
10
|
+
/** Wall-clock limit of the whole transfer. */
|
|
11
|
+
limitMs: number;
|
|
12
|
+
/** Longest gap between two chunks of a watched source. */
|
|
13
|
+
idleMs: number;
|
|
14
|
+
/** The code a passed wall-clock or idle limit fails with. */
|
|
15
|
+
timedOutCode: string;
|
|
16
|
+
/** The code a watched source that goes on after the transfer ended fails with. */
|
|
17
|
+
failedCode: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ITransferBounds {
|
|
20
|
+
/** Aborts once the caller aborts, a limit passes or the transfer ends. */
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
/** Ends the transfer: clears its timers and aborts `signal`. Idempotent. */
|
|
23
|
+
end: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* The error the transfer fails with: the reason that ended it (`request-aborted`
|
|
26
|
+
* or the timeout code), else `errorArg` when it is a `ForgeInventoryError`,
|
|
27
|
+
* else `fallbackArg`.
|
|
28
|
+
*/
|
|
29
|
+
failure: (errorArg: unknown, fallbackArg: string) => validation.ForgeInventoryError;
|
|
30
|
+
/** Runs one step of the transfer; it rejects as soon as the transfer ends, even if the step does not. */
|
|
31
|
+
run: <T>(stepArg: () => Promise<T>) => Promise<T>;
|
|
32
|
+
/** Re-arms the idle limit, before a step that waits on the network without a watched source. */
|
|
33
|
+
touch: () => void;
|
|
34
|
+
/** Passes chunks on and re-arms the idle limit on each one. */
|
|
35
|
+
watch: (sourceArg: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array, void, undefined>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The bounds of one streamed transfer: the caller's signal, a wall-clock limit
|
|
39
|
+
* and an idle limit that `watch` re-arms on every chunk. Whichever ends it
|
|
40
|
+
* first decides the failure code. Every transfer must be ended with `end`.
|
|
41
|
+
*/
|
|
42
|
+
export declare const createTransferBounds: (signalArg: AbortSignal | undefined, optionsArg: ITransferBoundsOptions) => ITransferBounds;
|
|
43
|
+
export interface IHeldBackCodes {
|
|
44
|
+
/** A chunk that is not a `Uint8Array`. */
|
|
45
|
+
invalidChunk: string;
|
|
46
|
+
/** More bytes than the declared size. */
|
|
47
|
+
overrun: string;
|
|
48
|
+
/** Fewer bytes than the declared size. */
|
|
49
|
+
short: string;
|
|
50
|
+
}
|
|
51
|
+
export interface IHeldBackChecks {
|
|
52
|
+
/** Sees every non-empty chunk in order, before it is passed on. */
|
|
53
|
+
inspect?: (chunkArg: Uint8Array) => void;
|
|
54
|
+
/** Runs once exactly `size` bytes have arrived, before the last chunk is passed on; a rejection keeps it back. */
|
|
55
|
+
complete?: () => void | Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Passes the bytes of `sourceArg` on while it counts them. The last chunk is
|
|
59
|
+
* held back until the source has ended with exactly `sizeArg` bytes and
|
|
60
|
+
* `checksArg.complete` has passed, so a consumer never receives a complete
|
|
61
|
+
* body that is wrong; otherwise it throws `codesArg.overrun` as soon as the
|
|
62
|
+
* size is exceeded, `codesArg.short` when the source ends early, or what
|
|
63
|
+
* `complete` threw.
|
|
64
|
+
*/
|
|
65
|
+
export declare function heldBackChunks(sourceArg: AsyncIterable<Uint8Array>, sizeArg: number, codesArg: IHeldBackCodes, checksArg?: IHeldBackChecks): AsyncGenerator<Uint8Array, void, undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* The readable stream of one transfer's bytes. It pulls `chunksArg` with
|
|
68
|
+
* backpressure and ends `boundsArg` and calls `onEndArg` as soon as it is
|
|
69
|
+
* destroyed, also while a pull is pending: ending the bounds aborts the
|
|
70
|
+
* request, which closes its connection, and the pending pull's result is
|
|
71
|
+
* dropped. (`Readable.from` waits for a pending pull before it finishes a
|
|
72
|
+
* destroy, so a stalled body would keep its connection until the idle limit.)
|
|
73
|
+
* The stream errors with what `chunksArg` throws.
|
|
74
|
+
*/
|
|
75
|
+
export declare const createTransferStream: (boundsArg: ITransferBounds, chunksArg: AsyncGenerator<Uint8Array, void, undefined>, onEndArg?: () => void) => InstanceType<typeof plugins.stream.Readable>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as validation from './forgeinventory.validation.js';
|
|
3
|
+
/** Response bytes read from an unused answer before the rest is discarded. */
|
|
4
|
+
const maxDiscardedResponseBytes = 64 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Reads and drops at most 64 KiB of a response body that carries nothing the
|
|
7
|
+
* caller uses, so its connection can be reused; a longer or failing body is
|
|
8
|
+
* discarded with its connection.
|
|
9
|
+
*/
|
|
10
|
+
export const discardBody = async (bodyArg) => {
|
|
11
|
+
if (!bodyArg)
|
|
12
|
+
return;
|
|
13
|
+
const chunks = bodyArg[Symbol.asyncIterator]();
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
try {
|
|
16
|
+
while (bytes <= maxDiscardedResponseBytes) {
|
|
17
|
+
const result = await chunks.next();
|
|
18
|
+
if (result.done)
|
|
19
|
+
return;
|
|
20
|
+
bytes += result.value.byteLength;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// The status already decided the outcome.
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
try {
|
|
28
|
+
await chunks.return?.();
|
|
29
|
+
}
|
|
30
|
+
catch { /* The connection is discarded. */ }
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The bounds of one streamed transfer: the caller's signal, a wall-clock limit
|
|
35
|
+
* and an idle limit that `watch` re-arms on every chunk. Whichever ends it
|
|
36
|
+
* first decides the failure code. Every transfer must be ended with `end`.
|
|
37
|
+
*/
|
|
38
|
+
export const createTransferBounds = (signalArg, optionsArg) => {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let reason = null;
|
|
41
|
+
const stop = (reasonArg) => {
|
|
42
|
+
reason ??= reasonArg;
|
|
43
|
+
controller.abort();
|
|
44
|
+
};
|
|
45
|
+
const abort = () => stop('request-aborted');
|
|
46
|
+
if (signalArg?.aborted)
|
|
47
|
+
abort();
|
|
48
|
+
signalArg?.addEventListener('abort', abort, { once: true });
|
|
49
|
+
const deadline = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.limitMs);
|
|
50
|
+
let idle;
|
|
51
|
+
const rearm = () => {
|
|
52
|
+
clearTimeout(idle);
|
|
53
|
+
if (!controller.signal.aborted)
|
|
54
|
+
idle = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.idleMs);
|
|
55
|
+
};
|
|
56
|
+
rearm();
|
|
57
|
+
const end = () => {
|
|
58
|
+
clearTimeout(deadline);
|
|
59
|
+
clearTimeout(idle);
|
|
60
|
+
signalArg?.removeEventListener('abort', abort);
|
|
61
|
+
stop('transfer-ended');
|
|
62
|
+
};
|
|
63
|
+
const failure = (errorArg, fallbackArg) => {
|
|
64
|
+
if (reason !== null && reason !== 'transfer-ended')
|
|
65
|
+
return new validation.ForgeInventoryError(reason);
|
|
66
|
+
return errorArg instanceof validation.ForgeInventoryError ? errorArg : new validation.ForgeInventoryError(fallbackArg);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
end,
|
|
71
|
+
failure,
|
|
72
|
+
touch: rearm,
|
|
73
|
+
run: async (stepArg) => {
|
|
74
|
+
if (controller.signal.aborted)
|
|
75
|
+
throw failure(undefined, 'request-aborted');
|
|
76
|
+
let onAbort;
|
|
77
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
78
|
+
onAbort = () => reject(failure(undefined, 'request-aborted'));
|
|
79
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
return await Promise.race([stepArg(), aborted]);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
watch: async function* (sourceArg) {
|
|
89
|
+
for await (const chunk of sourceArg) {
|
|
90
|
+
if (controller.signal.aborted)
|
|
91
|
+
throw failure(undefined, optionsArg.failedCode);
|
|
92
|
+
rearm();
|
|
93
|
+
yield chunk;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Passes the bytes of `sourceArg` on while it counts them. The last chunk is
|
|
100
|
+
* held back until the source has ended with exactly `sizeArg` bytes and
|
|
101
|
+
* `checksArg.complete` has passed, so a consumer never receives a complete
|
|
102
|
+
* body that is wrong; otherwise it throws `codesArg.overrun` as soon as the
|
|
103
|
+
* size is exceeded, `codesArg.short` when the source ends early, or what
|
|
104
|
+
* `complete` threw.
|
|
105
|
+
*/
|
|
106
|
+
export async function* heldBackChunks(sourceArg, sizeArg, codesArg, checksArg = {}) {
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
let held = null;
|
|
109
|
+
for await (const chunk of sourceArg) {
|
|
110
|
+
if (!(chunk instanceof Uint8Array))
|
|
111
|
+
throw new validation.ForgeInventoryError(codesArg.invalidChunk);
|
|
112
|
+
if (chunk.byteLength === 0)
|
|
113
|
+
continue;
|
|
114
|
+
bytes += chunk.byteLength;
|
|
115
|
+
if (bytes > sizeArg)
|
|
116
|
+
throw new validation.ForgeInventoryError(codesArg.overrun);
|
|
117
|
+
checksArg.inspect?.(chunk);
|
|
118
|
+
if (held)
|
|
119
|
+
yield held;
|
|
120
|
+
held = chunk;
|
|
121
|
+
}
|
|
122
|
+
if (bytes !== sizeArg)
|
|
123
|
+
throw new validation.ForgeInventoryError(codesArg.short);
|
|
124
|
+
await checksArg.complete?.();
|
|
125
|
+
if (held)
|
|
126
|
+
yield held;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The readable stream of one transfer's bytes. It pulls `chunksArg` with
|
|
130
|
+
* backpressure and ends `boundsArg` and calls `onEndArg` as soon as it is
|
|
131
|
+
* destroyed, also while a pull is pending: ending the bounds aborts the
|
|
132
|
+
* request, which closes its connection, and the pending pull's result is
|
|
133
|
+
* dropped. (`Readable.from` waits for a pending pull before it finishes a
|
|
134
|
+
* destroy, so a stalled body would keep its connection until the idle limit.)
|
|
135
|
+
* The stream errors with what `chunksArg` throws.
|
|
136
|
+
*/
|
|
137
|
+
export const createTransferStream = (boundsArg, chunksArg, onEndArg) => {
|
|
138
|
+
let pulling = false;
|
|
139
|
+
const stream = new plugins.stream.Readable({
|
|
140
|
+
read() {
|
|
141
|
+
if (pulling)
|
|
142
|
+
return;
|
|
143
|
+
pulling = true;
|
|
144
|
+
void (async () => {
|
|
145
|
+
try {
|
|
146
|
+
while (!stream.destroyed) {
|
|
147
|
+
const result = await chunksArg.next();
|
|
148
|
+
if (stream.destroyed)
|
|
149
|
+
return;
|
|
150
|
+
if (result.done)
|
|
151
|
+
return void stream.push(null);
|
|
152
|
+
if (!stream.push(result.value))
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
stream.destroy(error);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
pulling = false;
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
},
|
|
164
|
+
destroy(errorArg, callbackArg) {
|
|
165
|
+
boundsArg.end();
|
|
166
|
+
onEndArg?.();
|
|
167
|
+
// Runs the generator's cleanup once a pending pull has settled; nothing waits for it.
|
|
168
|
+
chunksArg.return(undefined).catch(() => undefined);
|
|
169
|
+
callbackArg(errorArg);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
return stream;
|
|
173
|
+
};
|
|
174
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9yZ2VpbnZlbnRvcnkudHJhbnNmZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9mb3JnZWludmVudG9yeS50cmFuc2Zlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQUN4QyxPQUFPLEtBQUssVUFBVSxNQUFNLGdDQUFnQyxDQUFDO0FBRTdELDhFQUE4RTtBQUM5RSxNQUFNLHlCQUF5QixHQUFHLEVBQUUsR0FBRyxJQUFJLENBQUM7QUFFNUM7Ozs7R0FJRztBQUNILE1BQU0sQ0FBQyxNQUFNLFdBQVcsR0FBRyxLQUFLLEVBQUUsT0FBeUMsRUFBaUIsRUFBRTtJQUM1RixJQUFJLENBQUMsT0FBTztRQUFFLE9BQU87SUFDckIsTUFBTSxNQUFNLEdBQUcsT0FBTyxDQUFDLE1BQU0sQ0FBQyxhQUFhLENBQUMsRUFBRSxDQUFDO0lBQy9DLElBQUksS0FBSyxHQUFHLENBQUMsQ0FBQztJQUNkLElBQUksQ0FBQztRQUNILE9BQU8sS0FBSyxJQUFJLHlCQUF5QixFQUFFLENBQUM7WUFDMUMsTUFBTSxNQUFNLEdBQUcsTUFBTSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUM7WUFDbkMsSUFBSSxNQUFNLENBQUMsSUFBSTtnQkFBRSxPQUFPO1lBQ3hCLEtBQUssSUFBSSxNQUFNLENBQUMsS0FBSyxDQUFDLFVBQVUsQ0FBQztRQUNuQyxDQUFDO0lBQ0gsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLDBDQUEwQztJQUM1QyxDQUFDO1lBQVMsQ0FBQztRQUNULElBQUksQ0FBQztZQUFDLE1BQU0sTUFBTSxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUM7UUFBQyxDQUFDO1FBQUMsTUFBTSxDQUFDLENBQUMsa0NBQWtDLENBQUMsQ0FBQztJQUMvRSxDQUFDO0FBQ0gsQ0FBQyxDQUFDO0FBZ0NGOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxDQUFDLFNBQWtDLEVB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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foss.global/codefeed",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The @foss.global/codefeed module generates commit/activity feeds from forge providers including Gitea and GitManager.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@foss.global/forgefixtures": "^0.3.1",
|
|
15
15
|
"@git.zone/tsbuild": "^6.0.2",
|
|
16
|
-
"@git.zone/tsbundle": "^3.
|
|
16
|
+
"@git.zone/tsbundle": "^3.4.0",
|
|
17
17
|
"@git.zone/tsrun": "^3.0.3",
|
|
18
18
|
"@git.zone/tstest": "^8.0.0",
|
|
19
19
|
"@push.rocks/qenv": "^8.2.1",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@foss.global/gitmanager": "^2.4.0",
|
|
24
|
-
"@foss.global/interfaces": "^2.
|
|
24
|
+
"@foss.global/interfaces": "^2.7.0",
|
|
25
25
|
"@push.rocks/lik": "^6.4.4",
|
|
26
26
|
"@push.rocks/smartnpm": "^2.1.3",
|
|
27
27
|
"@push.rocks/smarttime": "^5.0.1"
|
package/readme.md
CHANGED
|
@@ -103,8 +103,14 @@ const inventory = composeGiteaSourceInventory(discovery, results, [{ package: '@
|
|
|
103
103
|
|
|
104
104
|
A run is split into units: the instance, each owner and each repository. `measureUnit` never rejects; a unit whose primary record cannot be read becomes `failed` with a sanitized reason. A resumed run measures failed units, and units holding a partial measure, again and passes all results to `composeGiteaSourceInventory`, where the last result per unit wins. The composed document is deterministic: it carries no timestamps, sorts owners, repositories, packages and reasons, and does not depend on the order of the results. An aborted signal or a rejected `onResult` aborts the units in flight, hands over no further result, and rejects `measureUnits` once every worker has stopped.
|
|
105
105
|
|
|
106
|
+
Discovery records each repository's Gitea `id` as a canonical decimal string, and `units()` passes it into the repository unit. A unit with an `id` fails with `repository-identity-changed` when its name answers with another id, as after a rename and a new repository under the old name; a unit without one, from a discovery stored by an earlier version, is checked by name only. `composeGiteaSourceInventory` counts a repository result for another id than discovery recorded as a failed unit (`repository: unit failed: repository-identity-changed`), and adds `repository: duplicate-id` when two measured repositories share one id, which a discovery without ids cannot rule out.
|
|
107
|
+
|
|
108
|
+
`lookupRepository(id)` reads one repository by its id through `GET /api/v1/repositories/{id}`, which keeps answering after a rename or transfer. It resolves `{ status: 'present', id, owner, name }` with the name Gitea gives the repository now, or `{ status: 'absent', id }` only when Gitea answers 404, which a site administrator gets only for a deleted repository. Every other failed read rejects with its code, so a timeout or a 500 never counts as a deletion; an id that is not a canonical decimal rejects with `invalid-repository-id` without a request.
|
|
109
|
+
|
|
106
110
|
What is measured: users with their account flags, two-factor and SSH/GPG keys; organizations, members, teams, team memberships and repository grants; repositories with their flags, Git refs split into branches, tags, pull request refs and other refs, Git objects and their uncompressed bytes, LFS objects referenced anywhere in history with the bytes Gitea holds and the number it lacks, issues, pull requests, comments, reviews, review comments, labels, milestones, reactions and attachments; releases including drafts, assets and asset bytes; wiki pages; collaborators, team grants, deploy keys, webhooks, branch and tag protections and push mirrors; packages per type with versions, files and bytes; system webhooks, Actions runners, workflows, runs, artifacts and the number of Actions secrets and variables; unadopted repositories.
|
|
107
111
|
|
|
112
|
+
Beyond counts, each repository keeps records of what the migration recreates. `releaseRecords` lists every release, drafts and prereleases included, sorted by numeric id: tag name, target commitish, name, body, the draft and prerelease flags, created and published times as Gitea reports them (a draft reports its creation time as published, and its tag does not exist as a Git ref yet), an author snapshot (account id and login; Gitea's placeholder for a deleted account has id `-1`) and its assets sorted by numeric id with id, storage `uuid`, name, size, download count and creation time. Gitea exposes no content digest, so a record carries none; a replaced file carries another id and uuid, and the download count moves with every download, so it never decides whether a release changed. `protections` holds the branch protection rules sorted by rule name (code-unit order) with every restriction Gitea reads back, allowlists included, and the tag protection rules sorted by numeric id with their name pattern and allowlist. `releaseRecords` is null while a release count is not complete, and `protections` is null while either protection count is not; the counts then say why.
|
|
113
|
+
|
|
108
114
|
Secrets are counted, never named: the report carries no tokens, Actions secret or variable values or names, webhook secrets or mirror remote URLs. Reasons are adapter codes or fixed text, never response bodies or URLs. Gitea has no read that lists user-scoped Actions secrets; that is listed in `limitations`, since they are reissued. User-scoped webhooks, variables and runners are read as that user through the `Sudo` header.
|
|
109
115
|
|
|
110
116
|
Git-level measures come from a mirror clone of every ref Gitea advertises, `refs/pull/*` included, made with `@foss.global/gitmanager` in a fresh directory below `gitWorkDir` and removed afterwards. The reader's login and token form Basic credentials; the credential reaches Git only through GIT_CONFIG_* entries in the child process environment; it is never in the URL, the command line or any file. The private CA list applies to the clone as well. The mirror's HEAD becomes the repository's `head`: the ref it names as `target` and whether it `resolves`. In a repository that is not `empty`, a HEAD that does not resolve names a missing branch, such as a default branch deleted at the source, and a `target` other than `refs/heads/<defaultBranch>` means Gitea's setting and its Git HEAD disagree. `head` is null when the mirror or its refs could not be read. A detached source HEAD is one such case: GitManager's mirror import requires a symbolic HEAD, so the clone fails with `git-mirror-failed`, and this reader never reports a `target` of null. The mirror's distinct LFS pointers, from every reachable revision, are checked against Gitea in batches of 100: an object with a download action counts as held, one answered with error 404 as missing, and any other answer makes the LFS measures `partial`. A clone that fails or exceeds `gitTimeoutMs` makes every Git and LFS measure of that repository `partial`; only the API-reported size stays complete. A clone starts only once its scratch reservation, twice the reported size plus 64 MiB, fits the free space of the file system that holds `gitWorkDir`, counting the reservations of the clones already in flight from every reader in the process. A clone that does not fit waits for them, in arrival order; one that does not fit even alone makes every Git and LFS measure of its repository `partial` with `git-workdir-too-small`, so the inventory stays incomplete. Size `gitWorkDir` for the largest repositories measured at once.
|
|
@@ -116,7 +122,7 @@ Listings page until an empty page or the `x-total-count` total is reached. A tot
|
|
|
116
122
|
|
|
117
123
|
### Gitea source transfer
|
|
118
124
|
|
|
119
|
-
`GiteaSourceTransfer` reads one repository's Git data
|
|
125
|
+
`GiteaSourceTransfer` reads one repository's Git data, LFS objects and release assets for the one-time migration, with the same reader credentials as the inventory. It never writes to Gitea: Git is read only through `git-upload-pack`, LFS only through batch `download` requests and their download actions, release assets only through GETs of their attachment.
|
|
120
126
|
|
|
121
127
|
```ts
|
|
122
128
|
import { GitManager } from '@foss.global/gitmanager';
|
|
@@ -126,7 +132,8 @@ const source = new GiteaSourceTransfer({
|
|
|
126
132
|
baseUrl: 'https://gitea.example.com', // HTTPS only
|
|
127
133
|
login: readerLogin,
|
|
128
134
|
token: readerToken, // read it from a file; never pass it on a command line
|
|
129
|
-
// caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
|
|
135
|
+
// caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
|
|
136
|
+
// lfsTransferTimeoutMs: 30 * 60 * 1000, releaseAssetTimeoutMs: 30 * 60 * 1000,
|
|
130
137
|
});
|
|
131
138
|
|
|
132
139
|
// A started GitManager rooted in the caller's own scratch space; codefeed creates no scratch roots.
|
|
@@ -143,6 +150,10 @@ if (download.status === 'missing') {
|
|
|
143
150
|
} else {
|
|
144
151
|
await pipeline(download.stream, destination); // errors unless the bytes are exactly the object
|
|
145
152
|
}
|
|
153
|
+
|
|
154
|
+
// A release asset as the inventory recorded it: { id, uuid, name, size } from releaseRecords[].assets[].
|
|
155
|
+
const asset = await source.downloadReleaseAsset({ owner: 'team', name: 'app', id: '11' }, release.id, recordedAsset, { signal });
|
|
156
|
+
await pipeline(asset.stream, destination); // errors unless exactly `size` bytes of the recorded asset arrived
|
|
146
157
|
```
|
|
147
158
|
|
|
148
159
|
A source repository is named by `owner`, `name` and its Gitea repository `id`, a canonical decimal string as the inventory records it; anything else rejects with `invalid-repository`. Git and the LFS API reach a repository only by name, and a name can move to another repository between two reads: Gitea answers a renamed repository's old path with a redirect, and a new repository can take the old name. Every source read is therefore bracketed by an identity read of `GET /api/v1/repositories/{id}`, which must still name `owner/name` (compared case-insensitively, as Gitea does). A repository that was renamed, or whose name now belongs to another repository, rejects with `repository-identity-changed`; a deleted one rejects with `http-404`. A transfer that fails is explained the same way when the identity read shows one of those two causes.
|
|
@@ -153,6 +164,8 @@ While the import runs, GitManager holds `target`: writes to it, such as `initBar
|
|
|
153
164
|
|
|
154
165
|
`downloadLfsObject` sends one batch `download` request with the reader's Basic credentials, then a GET of the action's href with the action's headers only, and then reads the identity; when that read rejects, the stream is destroyed and the call rejects with its code. A missing object resolves as `{ status: 'missing' }`, so a caller can list the missing oids; any other object error rejects with `lfs-object-error-<code>`, such as `lfs-object-error-422` for a size Gitea disagrees with. The returned stream never holds the whole object: it counts and hashes the bytes as they pass and holds back the last chunk until the body ended with exactly `size` bytes whose SHA-256 is `oid`. Otherwise it errors with `lfs-size-overrun`, `lfs-short-body` or `lfs-digest-mismatch`; a `content-length` other than `size` rejects with `lfs-size-mismatch` before streaming. An abort errors it with `request-aborted`, a transfer longer than `lfsTransferTimeoutMs` or idle for `requestTimeoutMs` with `lfs-transfer-timed-out`. Destroying the stream ends the download.
|
|
155
166
|
|
|
167
|
+
`downloadReleaseAsset(repository, releaseId, asset)` takes the release id and the asset's `id`, `uuid`, `name` and `size` as the inventory recorded them; anything else rejects with `release-asset-invalid` before any request. It reads the repository by id, then `GET /api/v1/repos/{owner}/{name}/releases/{releaseId}`, which must still carry an asset with the same id, uuid, name and size; otherwise it rejects with `release-asset-identity-changed` (a deleted release with `http-404`). The download count is not compared. It then GETs `{baseUrl}/attachments/{uuid}` with the reader's `token` authorization: no redirect is followed and no range is asked for, so any answer but 200 rejects, with `http-<status>` or, for a refused redirect, `release-asset-download-failed`, and a `content-length` other than `size` rejects with `release-asset-size-mismatch`. No digest is computed while the bytes stream: the stream counts them and holds back the last chunk until exactly `size` bytes have arrived and both identity reads have passed once more, so a consumer never receives a complete asset that is short, long or no longer the recorded one. Otherwise it errors with `release-asset-size-overrun`, `release-asset-short-body`, `repository-identity-changed` or `release-asset-identity-changed`. Content digests come from the content itself, such as a publisher's `SHA256SUMS` asset or the importer's own hash. An abort errors it with `request-aborted`, a download longer than `releaseAssetTimeoutMs` (default 30 minutes, at most six hours) or idle for `requestTimeoutMs` with `release-asset-transfer-timed-out`. A failed request is explained by the identity reads as for `downloadLfsObject`. Destroying the stream ends the download; the download's connections close with the stream. The stream fails internally with `release-asset-transfer-failed` when body bytes still arrive after the download has ended, which happens only once the stream is destroyed, so a reader of the stream does not see it.
|
|
168
|
+
|
|
156
169
|
### Git LFS batch client
|
|
157
170
|
|
|
158
171
|
`LfsBatchClient` is a provider-neutral Git LFS batch API client with the `basic` transfer adapter, bound to one LFS endpoint and its credential. `GiteaSourceTransfer` downloads through it; the migration uploads to the native Git service through it.
|
|
@@ -343,7 +356,7 @@ The repo contains:
|
|
|
343
356
|
- A mocked pagination test that does not require network.
|
|
344
357
|
- A GitManager provider mapping test using a local test stub.
|
|
345
358
|
- A default-deny GitManager public-client mapping test using a local test stub.
|
|
346
|
-
- A mocked `GiteaSourceInventory` test, Git-level `GiteaSourceInventory` and `GiteaSourceTransfer` tests against a local HTTPS Git, LFS batch and
|
|
359
|
+
- A mocked `GiteaSourceInventory` test, Git-level `GiteaSourceInventory` and `GiteaSourceTransfer` tests and a release asset download test against a local HTTPS Git, LFS batch, REST and attachment server (needs `openssl` and `git http-backend`), and an opt-in one against a Docker Gitea from `@foss.global/forgefixtures`, run with `FORGEFIXTURES=gitea pnpm test` (Docker required).
|
|
347
360
|
- An `LfsBatchClient` test against local loopback LFS servers over HTTP and over HTTPS with a private CA.
|
|
348
361
|
|
|
349
362
|
Run tests:
|
package/ts/00_commitinfo_data.ts
CHANGED