@sous-io/sous 0.2.14 → 0.2.16
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/docs/markdown/commands.md +64 -13
- package/docs/markdown/repositories-authoring.md +53 -0
- package/docs/markdown/repositories-consuming.md +32 -1
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/namespace/list.ts +42 -21
- package/src/commands/namespace/show.ts +32 -12
- package/src/commands/recipe/list.ts +40 -23
- package/src/commands/recipe/show.ts +28 -6
- package/src/commands/repo/link.ts +400 -1
- package/src/commands/repo/list.ts +67 -10
- package/src/commands/repo/search.ts +68 -15
- package/src/commands/subscription/list.ts +98 -19
- package/src/lib/build-preparation.ts +44 -1
- package/src/lib/repos/catalog-display.ts +101 -2
- package/src/lib/repos/catalog-inputs.ts +145 -17
- package/src/lib/repos/catalog.ts +92 -5
- package/src/lib/repos/freshness.ts +56 -0
- package/src/lib/repos/git-clone.ts +468 -7
- package/src/lib/repos/providers/github.ts +1 -0
- package/src/lib/repos/providers/gitlab.ts +1 -0
- package/src/lib/repos/providers/http.ts +7 -2
- package/src/lib/repos/providers/index-cache.ts +56 -17
- package/src/lib/repos/providers/provider.ts +2 -0
- package/src/lib/repos/subscription-service.ts +138 -9
- package/src/utils/flags.ts +24 -0
|
@@ -27,13 +27,25 @@ export type GitResult = {
|
|
|
27
27
|
stderr: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/** Where and for how long one git command may run. */
|
|
31
|
+
export type GitRunOptions = {
|
|
32
|
+
/** The directory git runs in. */
|
|
33
|
+
cwd?: string;
|
|
34
|
+
/**
|
|
35
|
+
* How long git may run, in milliseconds, before it is stopped. A command that
|
|
36
|
+
* runs out of time comes back with a null status and a sentence saying so in
|
|
37
|
+
* `stderr`, never as an exception. Unset means no limit.
|
|
38
|
+
*/
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Runs one git command. Swappable so tests never need a real git binary.
|
|
32
44
|
*
|
|
33
45
|
* @param args - The arguments passed to git, without the leading "git".
|
|
34
|
-
* @param options - Where to run it.
|
|
46
|
+
* @param options - Where to run it, and for how long.
|
|
35
47
|
*/
|
|
36
|
-
export type GitRunner = (args: string[], options:
|
|
48
|
+
export type GitRunner = (args: string[], options: GitRunOptions) => GitResult;
|
|
37
49
|
|
|
38
50
|
/** Options shared by every function here. */
|
|
39
51
|
export type GitOptions = {
|
|
@@ -54,8 +66,22 @@ export const runGit: GitRunner = (args, options = {}) => {
|
|
|
54
66
|
// A clone must never stop to ask for a password; a prompt in a
|
|
55
67
|
// non-interactive run would hang the command with no explanation.
|
|
56
68
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
69
|
+
timeout: options.timeoutMs,
|
|
70
|
+
killSignal: "SIGKILL",
|
|
57
71
|
});
|
|
58
72
|
|
|
73
|
+
if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") {
|
|
74
|
+
const seconds = Math.round((options.timeoutMs ?? 0) / 1000);
|
|
75
|
+
const partial = (result.stderr ?? "").trim();
|
|
76
|
+
return {
|
|
77
|
+
status: null,
|
|
78
|
+
stdout: (result.stdout ?? "").trim(),
|
|
79
|
+
stderr:
|
|
80
|
+
`git did not finish within ${seconds} seconds and was stopped.` +
|
|
81
|
+
(partial.length > 0 ? `\n${partial}` : ""),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
if (result.error !== undefined) {
|
|
60
86
|
const reason = (result.error as NodeJS.ErrnoException).code === "ENOENT"
|
|
61
87
|
? "git is not installed, or is not on your PATH"
|
|
@@ -75,25 +101,42 @@ export const runGit: GitRunner = (args, options = {}) => {
|
|
|
75
101
|
};
|
|
76
102
|
};
|
|
77
103
|
|
|
78
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Runs git and throws a ConfigError on failure: a first line naming the step
|
|
106
|
+
* that failed, then the command, then git's own message, line for line. Git is
|
|
107
|
+
* the authority on what it will and will not do to a checkout, so its refusal
|
|
108
|
+
* is passed through rather than paraphrased.
|
|
109
|
+
*/
|
|
79
110
|
function runGitOrThrow(
|
|
80
111
|
args: string[],
|
|
81
|
-
options: { cwd?: string; runner?: GitRunner; what: string }
|
|
112
|
+
options: { cwd?: string; runner?: GitRunner; what: string; timeoutMs?: number }
|
|
82
113
|
): GitResult {
|
|
83
114
|
const runner = options.runner ?? runGit;
|
|
84
|
-
const result = runner(args, { cwd: options.cwd });
|
|
115
|
+
const result = runner(args, { cwd: options.cwd, timeoutMs: options.timeoutMs });
|
|
85
116
|
if (result.status !== 0) {
|
|
86
|
-
const detail = result
|
|
117
|
+
const detail = gitMessage(result);
|
|
118
|
+
const said =
|
|
119
|
+
detail.length > 0
|
|
120
|
+
? ` git said:\n${detail
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((line) => ` ${line}`)
|
|
123
|
+
.join("\n")}\n`
|
|
124
|
+
: "";
|
|
87
125
|
throw new ConfigError(
|
|
88
126
|
`${options.what} failed.\n` +
|
|
89
127
|
` Command: git ${args.join(" ")}\n` +
|
|
90
|
-
|
|
128
|
+
said +
|
|
91
129
|
` Fix the problem git reported, then run the command again.`
|
|
92
130
|
);
|
|
93
131
|
}
|
|
94
132
|
return result;
|
|
95
133
|
}
|
|
96
134
|
|
|
135
|
+
/** What git said about a result: its error output, or its standard output when that is all. */
|
|
136
|
+
function gitMessage(result: GitResult): string {
|
|
137
|
+
return result.stderr.length > 0 ? result.stderr : result.stdout;
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
/**
|
|
98
141
|
* True when the directory is inside a git working tree whose root is that same
|
|
99
142
|
* directory. A subdirectory of a checkout is deliberately not a checkout here:
|
|
@@ -280,6 +323,424 @@ export function looksLikeRepoUrl(value: string): boolean {
|
|
|
280
323
|
return value.startsWith("/") || value.startsWith("./") || value.startsWith("../");
|
|
281
324
|
}
|
|
282
325
|
|
|
326
|
+
// --- Branches and upstream ----------------------------------------------------------------------
|
|
327
|
+
//
|
|
328
|
+
// Everything below works on the `origin` remote, which is the one a clone
|
|
329
|
+
// creates and the one `remoteUrlOf` reads. Each function either reports a fact
|
|
330
|
+
// or runs exactly one git operation; when git refuses an operation, its own
|
|
331
|
+
// message is passed through under a line naming the step, and nothing here
|
|
332
|
+
// second-guesses it. The one exception is `discardableWork`, which exists
|
|
333
|
+
// because making a branch match upstream discards work without git warning
|
|
334
|
+
// about it.
|
|
335
|
+
|
|
336
|
+
/** The remote every function here reads from and fetches. */
|
|
337
|
+
export const UPSTREAM_REMOTE = "origin";
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* How long the fetch behind the divergence report may take, in milliseconds.
|
|
341
|
+
* It is tight on purpose: the report is a courtesy, and an unreachable host
|
|
342
|
+
* must fall back to a warning rather than hold the link up.
|
|
343
|
+
*/
|
|
344
|
+
export const UPSTREAM_CHECK_TIMEOUT_MS = 10_000;
|
|
345
|
+
|
|
346
|
+
/** What a fetch that is allowed to fail produced. */
|
|
347
|
+
export type FetchOutcome =
|
|
348
|
+
| { ok: true }
|
|
349
|
+
| {
|
|
350
|
+
ok: false;
|
|
351
|
+
/** Git's own explanation, or the sentence saying it ran out of time. */
|
|
352
|
+
reason: string;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The branch a checkout has checked out, or undefined when HEAD is detached
|
|
357
|
+
* (a tag or a bare commit is checked out instead of a branch).
|
|
358
|
+
*
|
|
359
|
+
* @param directory - The checkout to inspect.
|
|
360
|
+
* @param options - The git runner to use.
|
|
361
|
+
*/
|
|
362
|
+
export function currentBranch(directory: string, options: GitOptions = {}): string | undefined {
|
|
363
|
+
const runner = options.runner ?? runGit;
|
|
364
|
+
const result = runner(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: directory });
|
|
365
|
+
if (result.status !== 0 || result.stdout.length === 0) return undefined;
|
|
366
|
+
return result.stdout;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The abbreviated commit HEAD points at, for describing a detached checkout.
|
|
371
|
+
*
|
|
372
|
+
* @param directory - The checkout to inspect.
|
|
373
|
+
* @param options - The git runner to use.
|
|
374
|
+
*/
|
|
375
|
+
export function headCommit(directory: string, options: GitOptions = {}): string | undefined {
|
|
376
|
+
const runner = options.runner ?? runGit;
|
|
377
|
+
const result = runner(["rev-parse", "--short", "HEAD"], { cwd: directory });
|
|
378
|
+
if (result.status !== 0 || result.stdout.length === 0) return undefined;
|
|
379
|
+
return result.stdout;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The upstream repository's default branch, or undefined when it cannot be
|
|
384
|
+
* worked out. A clone records it as `origin/HEAD`, which is read first and
|
|
385
|
+
* needs no network; a checkout that lacks that record (one made with `git
|
|
386
|
+
* init` and a remote added later, say) is asked about over the network, under
|
|
387
|
+
* the same tight timeout as the upstream check.
|
|
388
|
+
*
|
|
389
|
+
* @param directory - The checkout to inspect.
|
|
390
|
+
* @param options - The git runner to use.
|
|
391
|
+
*/
|
|
392
|
+
export function defaultBranch(directory: string, options: GitOptions = {}): string | undefined {
|
|
393
|
+
const runner = options.runner ?? runGit;
|
|
394
|
+
const prefix = `${UPSTREAM_REMOTE}/`;
|
|
395
|
+
|
|
396
|
+
const local = runner(
|
|
397
|
+
["symbolic-ref", "--quiet", "--short", `refs/remotes/${UPSTREAM_REMOTE}/HEAD`],
|
|
398
|
+
{ cwd: directory }
|
|
399
|
+
);
|
|
400
|
+
if (local.status === 0 && local.stdout.startsWith(prefix)) {
|
|
401
|
+
return local.stdout.slice(prefix.length);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const remote = runner(["ls-remote", "--symref", UPSTREAM_REMOTE, "HEAD"], {
|
|
405
|
+
cwd: directory,
|
|
406
|
+
timeoutMs: UPSTREAM_CHECK_TIMEOUT_MS,
|
|
407
|
+
});
|
|
408
|
+
if (remote.status !== 0) return undefined;
|
|
409
|
+
const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(remote.stdout);
|
|
410
|
+
return match?.[1];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Fetches from upstream, allowing the fetch to fail. A fetch updates only the
|
|
415
|
+
* remote-tracking refs, never the user's files or branches, which is why the
|
|
416
|
+
* divergence report may run one unasked.
|
|
417
|
+
*
|
|
418
|
+
* @param directory - The checkout to fetch into.
|
|
419
|
+
* @param options - The git runner to use, and how long the fetch may take.
|
|
420
|
+
*/
|
|
421
|
+
export function tryFetchUpstream(
|
|
422
|
+
directory: string,
|
|
423
|
+
options: GitOptions & { timeoutMs?: number } = {}
|
|
424
|
+
): FetchOutcome {
|
|
425
|
+
const runner = options.runner ?? runGit;
|
|
426
|
+
const result = runner(["fetch", "--quiet", UPSTREAM_REMOTE], {
|
|
427
|
+
cwd: directory,
|
|
428
|
+
timeoutMs: options.timeoutMs ?? UPSTREAM_CHECK_TIMEOUT_MS,
|
|
429
|
+
});
|
|
430
|
+
if (result.status === 0) return { ok: true };
|
|
431
|
+
const reason = gitMessage(result);
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
reason: reason.length > 0 ? reason : `git exited with status ${String(result.status)}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* When this checkout last heard from upstream, as far as it recorded: the
|
|
440
|
+
* newer of the last fetch and the last update to the default branch's
|
|
441
|
+
* remote-tracking ref (a clone writes the second and not the first).
|
|
442
|
+
* Undefined when neither record exists.
|
|
443
|
+
*
|
|
444
|
+
* @param directory - The checkout to inspect.
|
|
445
|
+
* @param branch - The upstream default branch, when it is known.
|
|
446
|
+
* @param options - The git runner to use.
|
|
447
|
+
*/
|
|
448
|
+
export function lastFetchedAt(
|
|
449
|
+
directory: string,
|
|
450
|
+
branch: string | undefined,
|
|
451
|
+
options: GitOptions = {}
|
|
452
|
+
): Date | undefined {
|
|
453
|
+
const runner = options.runner ?? runGit;
|
|
454
|
+
const records = ["FETCH_HEAD"];
|
|
455
|
+
if (branch !== undefined) records.push(`logs/refs/remotes/${UPSTREAM_REMOTE}/${branch}`);
|
|
456
|
+
|
|
457
|
+
let newest: Date | undefined;
|
|
458
|
+
for (const record of records) {
|
|
459
|
+
const located = runner(["rev-parse", "--git-path", record], { cwd: directory });
|
|
460
|
+
if (located.status !== 0 || located.stdout.length === 0) continue;
|
|
461
|
+
const file = path.resolve(directory, located.stdout);
|
|
462
|
+
try {
|
|
463
|
+
const modified = fs.statSync(file).mtime;
|
|
464
|
+
if (newest === undefined || modified > newest) newest = modified;
|
|
465
|
+
} catch {
|
|
466
|
+
// No such record; the other one may still exist.
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return newest;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** How a checkout's current state compares with upstream's default branch. */
|
|
473
|
+
export type UpstreamComparison = {
|
|
474
|
+
/** The branch checked out, or undefined when HEAD is detached. */
|
|
475
|
+
branch: string | undefined;
|
|
476
|
+
/** The commit HEAD points at, abbreviated. */
|
|
477
|
+
commit: string | undefined;
|
|
478
|
+
/** The upstream default branch compared against. */
|
|
479
|
+
defaultBranch: string;
|
|
480
|
+
/**
|
|
481
|
+
* True when every commit on HEAD is already on the upstream default branch;
|
|
482
|
+
* undefined when git could not tell (the remote-tracking ref is missing).
|
|
483
|
+
*/
|
|
484
|
+
merged: boolean | undefined;
|
|
485
|
+
/** Commits on the upstream default branch that HEAD lacks; undefined when git could not tell. */
|
|
486
|
+
behind: number | undefined;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Compares a checkout's HEAD with the upstream default branch, from the
|
|
491
|
+
* remote-tracking refs as they stand. It runs no fetch of its own.
|
|
492
|
+
*
|
|
493
|
+
* @param directory - The checkout to inspect.
|
|
494
|
+
* @param branch - The upstream default branch.
|
|
495
|
+
* @param options - The git runner to use.
|
|
496
|
+
*/
|
|
497
|
+
export function compareWithUpstream(
|
|
498
|
+
directory: string,
|
|
499
|
+
branch: string,
|
|
500
|
+
options: GitOptions = {}
|
|
501
|
+
): UpstreamComparison {
|
|
502
|
+
const runner = options.runner ?? runGit;
|
|
503
|
+
const upstream = `${UPSTREAM_REMOTE}/${branch}`;
|
|
504
|
+
|
|
505
|
+
const ancestor = runner(["merge-base", "--is-ancestor", "HEAD", upstream], {
|
|
506
|
+
cwd: directory,
|
|
507
|
+
});
|
|
508
|
+
const merged = ancestor.status === 0 ? true : ancestor.status === 1 ? false : undefined;
|
|
509
|
+
|
|
510
|
+
const count = runner(["rev-list", "--count", `HEAD..${upstream}`], { cwd: directory });
|
|
511
|
+
const parsed = Number.parseInt(count.stdout, 10);
|
|
512
|
+
const behind = count.status === 0 && Number.isFinite(parsed) ? parsed : undefined;
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
branch: currentBranch(directory, options),
|
|
516
|
+
commit: headCommit(directory, options),
|
|
517
|
+
defaultBranch: branch,
|
|
518
|
+
merged,
|
|
519
|
+
behind,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Asks git whether a name is a valid branch name, and passes its refusal
|
|
525
|
+
* through when it is not. Every name the user types goes through this before
|
|
526
|
+
* it reaches any other git command, which is also what stops a name that starts
|
|
527
|
+
* with a dash from being read as an option.
|
|
528
|
+
*
|
|
529
|
+
* @param directory - The checkout the name is for.
|
|
530
|
+
* @param name - The branch name as the user typed it.
|
|
531
|
+
* @param options - The git runner to use.
|
|
532
|
+
*/
|
|
533
|
+
export function assertBranchName(directory: string, name: string, options: GitOptions = {}): void {
|
|
534
|
+
runGitOrThrow(["check-ref-format", "--branch", name], {
|
|
535
|
+
cwd: directory,
|
|
536
|
+
runner: options.runner,
|
|
537
|
+
what: `Checking the branch name '${name}'`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* True when the checkout has a local branch of that name.
|
|
543
|
+
*
|
|
544
|
+
* @param directory - The checkout to inspect.
|
|
545
|
+
* @param name - The branch name.
|
|
546
|
+
* @param options - The git runner to use.
|
|
547
|
+
*/
|
|
548
|
+
export function localBranchExists(
|
|
549
|
+
directory: string,
|
|
550
|
+
name: string,
|
|
551
|
+
options: GitOptions = {}
|
|
552
|
+
): boolean {
|
|
553
|
+
const runner = options.runner ?? runGit;
|
|
554
|
+
const result = runner(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], {
|
|
555
|
+
cwd: directory,
|
|
556
|
+
});
|
|
557
|
+
return result.status === 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Fetches one named branch from upstream into its remote-tracking ref.
|
|
562
|
+
*
|
|
563
|
+
* A clone sous makes is single-branch, so its fetch configuration covers only
|
|
564
|
+
* the default branch, and neither a plain fetch nor `git switch` would ever see
|
|
565
|
+
* another one. The branch is therefore fetched by an explicit refspec, and then
|
|
566
|
+
* added to the remote's fetch list (`git remote set-branches --add`), so later
|
|
567
|
+
* fetches keep it current and `git switch` can find it. A checkout whose fetch
|
|
568
|
+
* configuration already covers the branch is left as it is.
|
|
569
|
+
*
|
|
570
|
+
* @param directory - The checkout to fetch into.
|
|
571
|
+
* @param name - The branch to fetch.
|
|
572
|
+
* @param options - The git runner to use.
|
|
573
|
+
*/
|
|
574
|
+
export function fetchBranch(directory: string, name: string, options: GitOptions = {}): void {
|
|
575
|
+
runGitOrThrow(
|
|
576
|
+
[
|
|
577
|
+
"fetch",
|
|
578
|
+
"--quiet",
|
|
579
|
+
UPSTREAM_REMOTE,
|
|
580
|
+
`+refs/heads/${name}:refs/remotes/${UPSTREAM_REMOTE}/${name}`,
|
|
581
|
+
],
|
|
582
|
+
{
|
|
583
|
+
cwd: directory,
|
|
584
|
+
runner: options.runner,
|
|
585
|
+
what: `Fetching the branch '${name}' from ${UPSTREAM_REMOTE}`,
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
if (fetchConfigCovers(directory, name, options)) return;
|
|
590
|
+
|
|
591
|
+
runGitOrThrow(["remote", "set-branches", "--add", UPSTREAM_REMOTE, name], {
|
|
592
|
+
cwd: directory,
|
|
593
|
+
runner: options.runner,
|
|
594
|
+
what: `Adding the branch '${name}' to the branches ${UPSTREAM_REMOTE} is fetched for`,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* True when the remote's configured fetch refspecs already bring the branch
|
|
600
|
+
* in: a wildcard over every branch, or the branch by name.
|
|
601
|
+
*/
|
|
602
|
+
function fetchConfigCovers(directory: string, name: string, options: GitOptions): boolean {
|
|
603
|
+
const runner = options.runner ?? runGit;
|
|
604
|
+
const result = runner(["config", "--get-all", `remote.${UPSTREAM_REMOTE}.fetch`], {
|
|
605
|
+
cwd: directory,
|
|
606
|
+
});
|
|
607
|
+
if (result.status !== 0) return false;
|
|
608
|
+
return result.stdout.split("\n").some((line) => {
|
|
609
|
+
const source = line.trim().replace(/^\+/, "").split(":")[0];
|
|
610
|
+
return source === "refs/heads/*" || source === `refs/heads/${name}`;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Switches the checkout to an existing branch with `git switch`, which also
|
|
616
|
+
* creates a local branch tracking an upstream one of the same name.
|
|
617
|
+
*
|
|
618
|
+
* @param directory - The checkout to switch.
|
|
619
|
+
* @param name - The branch to switch to.
|
|
620
|
+
* @param options - The git runner to use.
|
|
621
|
+
*/
|
|
622
|
+
export function switchBranch(directory: string, name: string, options: GitOptions = {}): void {
|
|
623
|
+
runGitOrThrow(["switch", name], {
|
|
624
|
+
cwd: directory,
|
|
625
|
+
runner: options.runner,
|
|
626
|
+
what: `Switching to the branch '${name}'`,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Creates a branch at a start point and switches to it, with `git switch
|
|
632
|
+
* --create`, which refuses a branch that already exists. The new branch tracks
|
|
633
|
+
* nothing, so pushing it never lands on the branch it started from.
|
|
634
|
+
*
|
|
635
|
+
* @param directory - The checkout to work in.
|
|
636
|
+
* @param name - The branch to create.
|
|
637
|
+
* @param startPoint - Where it starts, such as `origin/main`.
|
|
638
|
+
* @param options - The git runner to use.
|
|
639
|
+
*/
|
|
640
|
+
export function createBranch(
|
|
641
|
+
directory: string,
|
|
642
|
+
name: string,
|
|
643
|
+
startPoint: string,
|
|
644
|
+
options: GitOptions = {}
|
|
645
|
+
): void {
|
|
646
|
+
runGitOrThrow(["switch", "--create", name, "--no-track", startPoint], {
|
|
647
|
+
cwd: directory,
|
|
648
|
+
runner: options.runner,
|
|
649
|
+
what: `Creating the branch '${name}' from ${startPoint}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The local work that making a branch match upstream would throw away. */
|
|
654
|
+
export type DiscardableWork = {
|
|
655
|
+
/** Changes to tracked files that are not committed, in `git status --short` form. */
|
|
656
|
+
uncommitted: string[];
|
|
657
|
+
/** Commits on the local branch that its upstream counterpart lacks, one line each. */
|
|
658
|
+
localCommits: string[];
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Lists what `resetBranchToUpstream` would discard: uncommitted changes to
|
|
663
|
+
* tracked files (untracked files are left alone by it, so they are not listed),
|
|
664
|
+
* and commits on the local branch that the fetched upstream branch lacks. Run
|
|
665
|
+
* it after fetching the branch.
|
|
666
|
+
*
|
|
667
|
+
* @param directory - The checkout to inspect.
|
|
668
|
+
* @param name - The branch that would be made to match upstream.
|
|
669
|
+
* @param options - The git runner to use.
|
|
670
|
+
*/
|
|
671
|
+
export function discardableWork(
|
|
672
|
+
directory: string,
|
|
673
|
+
name: string,
|
|
674
|
+
options: GitOptions = {}
|
|
675
|
+
): DiscardableWork {
|
|
676
|
+
const status = runGitOrThrow(["status", "--porcelain", "--untracked-files=no"], {
|
|
677
|
+
cwd: directory,
|
|
678
|
+
runner: options.runner,
|
|
679
|
+
what: "Listing the uncommitted changes",
|
|
680
|
+
});
|
|
681
|
+
const uncommitted = status.stdout
|
|
682
|
+
.split("\n")
|
|
683
|
+
.map((line) => line.trimEnd())
|
|
684
|
+
.filter((line) => line.length > 0);
|
|
685
|
+
|
|
686
|
+
let localCommits: string[] = [];
|
|
687
|
+
if (localBranchExists(directory, name, options)) {
|
|
688
|
+
const log = runGitOrThrow(
|
|
689
|
+
["log", "--oneline", "--no-decorate", `refs/remotes/${UPSTREAM_REMOTE}/${name}..refs/heads/${name}`],
|
|
690
|
+
{
|
|
691
|
+
cwd: directory,
|
|
692
|
+
runner: options.runner,
|
|
693
|
+
what: `Listing the commits on '${name}' that ${UPSTREAM_REMOTE} does not have`,
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
localCommits = log.stdout
|
|
697
|
+
.split("\n")
|
|
698
|
+
.map((line) => line.trim())
|
|
699
|
+
.filter((line) => line.length > 0);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { uncommitted, localCommits };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Switches to a branch and makes it match its fetched upstream counterpart
|
|
707
|
+
* exactly, creating the local branch when there is none. Uncommitted changes to
|
|
708
|
+
* tracked files and local commits upstream lacks are discarded, which is why a
|
|
709
|
+
* caller lists them with `discardableWork` and asks first. Every other branch
|
|
710
|
+
* is left as it is.
|
|
711
|
+
*
|
|
712
|
+
* @param directory - The checkout to work in.
|
|
713
|
+
* @param name - The branch to update.
|
|
714
|
+
* @param options - The git runner to use.
|
|
715
|
+
*/
|
|
716
|
+
export function resetBranchToUpstream(
|
|
717
|
+
directory: string,
|
|
718
|
+
name: string,
|
|
719
|
+
options: GitOptions = {}
|
|
720
|
+
): void {
|
|
721
|
+
runGitOrThrow(
|
|
722
|
+
["switch", "--discard-changes", "--force-create", name, `${UPSTREAM_REMOTE}/${name}`],
|
|
723
|
+
{
|
|
724
|
+
cwd: directory,
|
|
725
|
+
runner: options.runner,
|
|
726
|
+
what: `Making the branch '${name}' match ${UPSTREAM_REMOTE}/${name}`,
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The name `--generate-branch` gives a new branch: `sous/edit-<YYYYMMDD>-<HHMM>`,
|
|
733
|
+
* in local time.
|
|
734
|
+
*
|
|
735
|
+
* @param now - The moment to name it after. Defaults to now.
|
|
736
|
+
*/
|
|
737
|
+
export function generatedBranchName(now: Date = new Date()): string {
|
|
738
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
739
|
+
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
740
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
741
|
+
return `sous/edit-${date}-${time}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
283
744
|
// --- Small filesystem helpers -------------------------------------------------------------------
|
|
284
745
|
|
|
285
746
|
/** Replaces anything outside a safe path segment, so a URL can never escape the store. */
|
|
@@ -113,6 +113,7 @@ export class GithubProvider extends ProviderBase {
|
|
|
113
113
|
...(options.fetchImpl === undefined
|
|
114
114
|
? {}
|
|
115
115
|
: { fetchImpl: options.fetchImpl as FetchLike }),
|
|
116
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
116
117
|
label: "repo index",
|
|
117
118
|
});
|
|
118
119
|
|
|
@@ -119,6 +119,7 @@ export class GitlabProvider extends ProviderBase {
|
|
|
119
119
|
...(options.fetchImpl === undefined
|
|
120
120
|
? {}
|
|
121
121
|
: { fetchImpl: options.fetchImpl as FetchLike }),
|
|
122
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
122
123
|
label: "repo index",
|
|
123
124
|
});
|
|
124
125
|
|
|
@@ -11,7 +11,7 @@ import { ConfigError } from "../../errors.js";
|
|
|
11
11
|
/** The shape of `fetch` this module needs; the global satisfies it. */
|
|
12
12
|
export type FetchLike = (
|
|
13
13
|
url: string,
|
|
14
|
-
init?: { headers?: Record<string, string
|
|
14
|
+
init?: { headers?: Record<string, string>; signal?: AbortSignal }
|
|
15
15
|
) => Promise<{
|
|
16
16
|
ok: boolean;
|
|
17
17
|
status: number;
|
|
@@ -36,6 +36,8 @@ export type FetchTextOptions = {
|
|
|
36
36
|
fetchImpl?: FetchLike;
|
|
37
37
|
/** What the URL is, named in error messages (for example "repo index"). */
|
|
38
38
|
label?: string;
|
|
39
|
+
/** Cancels the request, for a caller that will not wait past a deadline. */
|
|
40
|
+
signal?: AbortSignal;
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -67,7 +69,10 @@ export async function fetchText(
|
|
|
67
69
|
|
|
68
70
|
let response: Awaited<ReturnType<FetchLike>>;
|
|
69
71
|
try {
|
|
70
|
-
response = await fetchImpl(
|
|
72
|
+
response = await fetchImpl(
|
|
73
|
+
url,
|
|
74
|
+
options.signal === undefined ? { headers } : { headers, signal: options.signal }
|
|
75
|
+
);
|
|
71
76
|
} catch (error) {
|
|
72
77
|
throw new ConfigError(
|
|
73
78
|
`Sous could not reach ${url} while fetching the ${label}.\n` +
|
|
@@ -97,8 +97,13 @@ export type GetIndexOptions = {
|
|
|
97
97
|
* shown; the identity is used when nothing supplies one.
|
|
98
98
|
*/
|
|
99
99
|
label?: string;
|
|
100
|
+
/** Cancels the request, for a caller that will not wait past a deadline. */
|
|
101
|
+
signal?: AbortSignal;
|
|
100
102
|
};
|
|
101
103
|
|
|
104
|
+
/** Which repository to read from upstream, without touching the cache. */
|
|
105
|
+
export type FetchUpstreamOptions = Pick<GetIndexOptions, "url" | "provider" | "signal">;
|
|
106
|
+
|
|
102
107
|
/** How the cache is built. */
|
|
103
108
|
export type IndexCacheOptions = {
|
|
104
109
|
/** The store's root directory; the cache lives in a subdirectory of it. */
|
|
@@ -334,23 +339,7 @@ export class IndexCache {
|
|
|
334
339
|
* @param options - The repository URL and its provider.
|
|
335
340
|
*/
|
|
336
341
|
async refresh(identity: string, options: GetIndexOptions): Promise<IndexLookup> {
|
|
337
|
-
const
|
|
338
|
-
const repo = provider.canonicalize(options.url);
|
|
339
|
-
const fetched = await provider.fetchIndex(repo, this.providerOptions);
|
|
340
|
-
|
|
341
|
-
let parsed: unknown;
|
|
342
|
-
try {
|
|
343
|
-
parsed = JSON.parse(fetched.text);
|
|
344
|
-
} catch (error) {
|
|
345
|
-
throw new ConfigError(
|
|
346
|
-
`The index that ${options.url} published is not valid JSON.\n` +
|
|
347
|
-
` ${(error as Error).message}\n` +
|
|
348
|
-
` A repository's index is written by 'sous repo release'; this one may be ` +
|
|
349
|
-
`damaged or may not be a sous repository at all.`
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const index = parseIndexFile(parsed, `${options.url} (${fetched.ref})`);
|
|
342
|
+
const { index, fetched } = await this.fetchFromProvider(options);
|
|
354
343
|
const timestamp = this.now().toISOString();
|
|
355
344
|
const meta: IndexMeta = {
|
|
356
345
|
fetchedAt: timestamp,
|
|
@@ -369,6 +358,56 @@ export class IndexCache {
|
|
|
369
358
|
return { index: this.applyOverlay(identity, index), source: "network", meta };
|
|
370
359
|
}
|
|
371
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Reads a repository's index from upstream and returns it, WITHOUT writing
|
|
363
|
+
* anything: neither the cached copy nor its sidecar changes. This is what a
|
|
364
|
+
* browsing command reading upstream uses, because only a command that
|
|
365
|
+
* resolves versions should change what the cache holds. Any installed overlay
|
|
366
|
+
* is folded into the answer, as it is for a cached copy. A failure is raised;
|
|
367
|
+
* the caller decides whether a cached copy stands in for it.
|
|
368
|
+
*
|
|
369
|
+
* @param identity - The repository's canonical identity, for the overlay.
|
|
370
|
+
* @param options - The repository URL, its provider, and an optional cancel signal.
|
|
371
|
+
*/
|
|
372
|
+
async fetchUpstream(identity: string, options: FetchUpstreamOptions): Promise<IndexFile> {
|
|
373
|
+
const { index } = await this.fetchFromProvider(options);
|
|
374
|
+
return this.applyOverlay(identity, index);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fetches and validates a repository's index through its provider. Writes
|
|
379
|
+
* nothing; `refresh` and `fetchUpstream` decide what happens next.
|
|
380
|
+
*
|
|
381
|
+
* @param options - The repository URL, its provider, and an optional cancel signal.
|
|
382
|
+
*/
|
|
383
|
+
private async fetchFromProvider(
|
|
384
|
+
options: FetchUpstreamOptions
|
|
385
|
+
): Promise<{ index: IndexFile; fetched: { ref: string; etag?: string } }> {
|
|
386
|
+
const provider = this.resolveProvider(options.url, options.provider);
|
|
387
|
+
const repo = provider.canonicalize(options.url);
|
|
388
|
+
const fetched = await provider.fetchIndex(
|
|
389
|
+
repo,
|
|
390
|
+
options.signal === undefined
|
|
391
|
+
? this.providerOptions
|
|
392
|
+
: { ...this.providerOptions, signal: options.signal }
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
let parsed: unknown;
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(fetched.text);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new ConfigError(
|
|
400
|
+
`The index that ${options.url} published is not valid JSON.\n` +
|
|
401
|
+
` ${(error as Error).message}\n` +
|
|
402
|
+
` A repository's index is written by 'sous repo release'; this one may be ` +
|
|
403
|
+
`damaged or may not be a sous repository at all.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const index = parseIndexFile(parsed, `${options.url} (${fetched.ref})`);
|
|
408
|
+
return { index, fetched };
|
|
409
|
+
}
|
|
410
|
+
|
|
372
411
|
/**
|
|
373
412
|
* Forgets a repository's cached index and sidecar, which is what removing a
|
|
374
413
|
* repository from a project does.
|
|
@@ -67,6 +67,8 @@ export type ProviderOptions = {
|
|
|
67
67
|
fetchImpl?: FetchLike;
|
|
68
68
|
/** How subprocesses are run. Defaults to spawning a real process. */
|
|
69
69
|
run?: CommandRunner;
|
|
70
|
+
/** Cancels an index request, for a caller that will not wait past a deadline. */
|
|
71
|
+
signal?: AbortSignal;
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
/** What an index fetch returns. */
|