@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
|
@@ -6,14 +6,17 @@
|
|
|
6
6
|
* and where its files land in this project. It reads only what sous already has
|
|
7
7
|
* on disk: the repository's cached index, the project's lockfile, and the
|
|
8
8
|
* recipe's own files when they are in the store or a linked working copy.
|
|
9
|
+
* `--latest` reads the repository's index from upstream instead, saving
|
|
10
|
+
* nothing, and `--installed` looks the ref up among installed recipes only.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { Args } from "@oclif/core";
|
|
12
14
|
import { BaseCommand } from "../../base-command.js";
|
|
13
15
|
import { resolveRootScope } from "../../lib/settings.js";
|
|
14
16
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
15
|
-
import {
|
|
17
|
+
import { loadCatalogContext } from "../../lib/repos/catalog-inputs.js";
|
|
16
18
|
import {
|
|
19
|
+
describeInstalled,
|
|
17
20
|
describeRecipe,
|
|
18
21
|
type RecipeContentListing,
|
|
19
22
|
type RecipeDependencyListing,
|
|
@@ -22,11 +25,14 @@ import {
|
|
|
22
25
|
} from "../../lib/repos/catalog.js";
|
|
23
26
|
import {
|
|
24
27
|
INDENT,
|
|
28
|
+
describeIndexSource,
|
|
25
29
|
describeVersionStatus,
|
|
26
30
|
factIf,
|
|
31
|
+
printBrowsingNotes,
|
|
27
32
|
printFacts,
|
|
28
33
|
} from "../../lib/repos/catalog-display.js";
|
|
29
34
|
import { renderTable, type TableColumn } from "../../utils/table.js";
|
|
35
|
+
import { browsingFlags } from "../../utils/flags.js";
|
|
30
36
|
import {
|
|
31
37
|
blankLine,
|
|
32
38
|
footer,
|
|
@@ -102,15 +108,17 @@ export default class RecipeShow extends BaseCommand {
|
|
|
102
108
|
}),
|
|
103
109
|
};
|
|
104
110
|
|
|
105
|
-
static flags = { ...BaseCommand.baseFlags };
|
|
111
|
+
static flags = { ...BaseCommand.baseFlags, ...browsingFlags() };
|
|
106
112
|
|
|
107
113
|
async run(): Promise<void> {
|
|
108
|
-
const { args } = await this.parse(RecipeShow);
|
|
114
|
+
const { args, flags } = await this.parse(RecipeShow);
|
|
109
115
|
|
|
110
116
|
showCommandVars({
|
|
111
117
|
Project: this.projectLabel,
|
|
112
118
|
Config: this.configContext.configPath,
|
|
113
119
|
Recipe: args.ref,
|
|
120
|
+
Reading: describeIndexSource(flags.latest),
|
|
121
|
+
...(flags.installed ? { Showing: "only what this project has installed" } : {}),
|
|
114
122
|
});
|
|
115
123
|
|
|
116
124
|
const service = subscriptionServiceFor({
|
|
@@ -119,14 +127,17 @@ export default class RecipeShow extends BaseCommand {
|
|
|
119
127
|
shellEnv: this.shellEnv,
|
|
120
128
|
});
|
|
121
129
|
|
|
122
|
-
const { inputs } =
|
|
130
|
+
const { inputs, notChecked } = await loadCatalogContext({
|
|
123
131
|
service,
|
|
124
132
|
sousDir: this.configContext.sousDir,
|
|
125
133
|
settings: this.settings,
|
|
126
134
|
scope: resolveRootScope(this.settings, this.configContext),
|
|
135
|
+
latest: flags.latest,
|
|
127
136
|
});
|
|
128
137
|
|
|
129
|
-
const detail =
|
|
138
|
+
const detail = flags.installed
|
|
139
|
+
? describeInstalled(inputs, args.ref, describeRecipe, "recipe")
|
|
140
|
+
: describeRecipe(inputs, args.ref);
|
|
130
141
|
|
|
131
142
|
heading(detail.key);
|
|
132
143
|
blankLine();
|
|
@@ -137,11 +148,22 @@ export default class RecipeShow extends BaseCommand {
|
|
|
137
148
|
...factIf("About", detail.description),
|
|
138
149
|
{ label: "Folder", lines: [detail.path] },
|
|
139
150
|
{ label: "Latest version", lines: [detail.latest ?? "none published"] },
|
|
140
|
-
{
|
|
151
|
+
{
|
|
152
|
+
label: flags.installed ? "Installed version" : "Pinned version",
|
|
153
|
+
lines: [detail.pinned ?? "this project pins none"],
|
|
154
|
+
},
|
|
155
|
+
...factIf(
|
|
156
|
+
"Linked",
|
|
157
|
+
detail.linkedPath === undefined
|
|
158
|
+
? undefined
|
|
159
|
+
: `builds currently read this recipe from the checkout at ${detail.linkedPath}`
|
|
160
|
+
),
|
|
141
161
|
{ label: "Subscribed", lines: [detail.subscribed ? "yes" : "no"] },
|
|
142
162
|
...factIf("Described below", detail.describing),
|
|
143
163
|
]);
|
|
144
164
|
|
|
165
|
+
printBrowsingNotes({ notChecked: notChecked.filter((name) => name === detail.repo) });
|
|
166
|
+
|
|
145
167
|
this.printVersions(detail.versions);
|
|
146
168
|
this.printDependencies(detail.dependencies, detail.manifestRead);
|
|
147
169
|
|
|
@@ -12,13 +12,30 @@ import { enabledRepos } from "../../lib/repos/defaults.js";
|
|
|
12
12
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
13
13
|
import type { LinkOrigin } from "../../lib/repos/formats/links-map.js";
|
|
14
14
|
import {
|
|
15
|
+
assertBranchName,
|
|
15
16
|
cloneRepo,
|
|
17
|
+
compareWithUpstream,
|
|
18
|
+
createBranch,
|
|
19
|
+
currentBranch,
|
|
20
|
+
defaultBranch,
|
|
21
|
+
discardableWork,
|
|
22
|
+
fetchBranch,
|
|
23
|
+
generatedBranchName,
|
|
16
24
|
isGitCheckout,
|
|
25
|
+
lastFetchedAt,
|
|
26
|
+
localBranchExists,
|
|
17
27
|
looksLikeRepoUrl,
|
|
18
28
|
remoteUrlOf,
|
|
19
29
|
repoSlugFromUrl,
|
|
30
|
+
resetBranchToUpstream,
|
|
20
31
|
sameRemote,
|
|
32
|
+
switchBranch,
|
|
33
|
+
tryFetchUpstream,
|
|
34
|
+
UPSTREAM_REMOTE,
|
|
35
|
+
type DiscardableWork,
|
|
21
36
|
} from "../../lib/repos/git-clone.js";
|
|
37
|
+
import { isInteractive, nonInteractiveError } from "../../lib/interactive.js";
|
|
38
|
+
import { askYesNo } from "../../utils/prompts.js";
|
|
22
39
|
import {
|
|
23
40
|
assertLocalRepoDirectory,
|
|
24
41
|
expandHomePath,
|
|
@@ -36,12 +53,16 @@ import {
|
|
|
36
53
|
writeProjectLinks,
|
|
37
54
|
} from "../../lib/repos/links.js";
|
|
38
55
|
import {
|
|
56
|
+
BULLET,
|
|
39
57
|
blankLine,
|
|
40
58
|
dryRunNotice,
|
|
41
59
|
footer,
|
|
42
60
|
heading,
|
|
43
61
|
log,
|
|
62
|
+
note,
|
|
63
|
+
paragraph,
|
|
44
64
|
showCommandVars,
|
|
65
|
+
showVariable,
|
|
45
66
|
showVariables,
|
|
46
67
|
warning,
|
|
47
68
|
} from "../../utils/formatting.js";
|
|
@@ -68,6 +89,15 @@ import {
|
|
|
68
89
|
* adding one. Naming a repository this project has not added therefore runs the
|
|
69
90
|
* same trust ceremony `sous repo add` runs, rather than skipping it; there is no
|
|
70
91
|
* way to read from a repository this project does not trust.
|
|
92
|
+
*
|
|
93
|
+
* A link says nothing about WHY a checkout is being read (authoring, running a
|
|
94
|
+
* teammate's branch, a local fork, debugging), so the command never changes a
|
|
95
|
+
* checkout on its own. A checkout that was already on disk is fetched and
|
|
96
|
+
* compared with upstream, which changes none of its files or branches; every
|
|
97
|
+
* change to it is an explicit flag (`--branch`, `--create-branch`,
|
|
98
|
+
* `--generate-branch`, `--latest`), carried out by git, whose refusals are
|
|
99
|
+
* passed through. `--latest` is the one place sous checks for itself, because
|
|
100
|
+
* making a branch match upstream discards local work without git warning.
|
|
71
101
|
*/
|
|
72
102
|
export default class RepoLink extends BaseCommand {
|
|
73
103
|
static description = [
|
|
@@ -79,6 +109,8 @@ export default class RepoLink extends BaseCommand {
|
|
|
79
109
|
"that clone.",
|
|
80
110
|
"'sous repo link <name-or-url> <path>' links the checkout at that path to that " +
|
|
81
111
|
"repository, and clones nothing.",
|
|
112
|
+
"A checkout that was already on disk is fetched and compared with upstream; " +
|
|
113
|
+
"only the branch flags and --latest change it.",
|
|
82
114
|
].join("\n");
|
|
83
115
|
|
|
84
116
|
/**
|
|
@@ -93,6 +125,9 @@ export default class RepoLink extends BaseCommand {
|
|
|
93
125
|
"<%= config.bin %> repo link sous-recipes ~/Projects/sous-recipes",
|
|
94
126
|
"<%= config.bin %> repo link https://github.com/sous-io/sous-recipes",
|
|
95
127
|
"<%= config.bin %> repo link sous-recipes --global",
|
|
128
|
+
"<%= config.bin %> repo link sous-recipes --latest",
|
|
129
|
+
"<%= config.bin %> repo link sous-recipes --generate-branch",
|
|
130
|
+
"<%= config.bin %> repo link sous-recipes --branch my-change --latest",
|
|
96
131
|
];
|
|
97
132
|
|
|
98
133
|
static args = {
|
|
@@ -124,6 +159,33 @@ export default class RepoLink extends BaseCommand {
|
|
|
124
159
|
description: "Print what would change without cloning or writing anything",
|
|
125
160
|
default: false,
|
|
126
161
|
}),
|
|
162
|
+
branch: Flags.string({
|
|
163
|
+
description: "Switch the checkout to this existing branch, fetching it from upstream first",
|
|
164
|
+
helpValue: "<name>",
|
|
165
|
+
exclusive: ["create-branch", "generate-branch"],
|
|
166
|
+
}),
|
|
167
|
+
"create-branch": Flags.string({
|
|
168
|
+
description: "Create this new branch in the checkout and switch to it",
|
|
169
|
+
helpValue: "<name>",
|
|
170
|
+
exclusive: ["branch", "generate-branch"],
|
|
171
|
+
}),
|
|
172
|
+
"generate-branch": Flags.boolean({
|
|
173
|
+
description: "Create a new branch named sous/edit-<date>-<time> in the checkout and switch to it",
|
|
174
|
+
exclusive: ["branch", "create-branch"],
|
|
175
|
+
}),
|
|
176
|
+
from: Flags.string({
|
|
177
|
+
description:
|
|
178
|
+
"Start the new branch from this branch instead of the repository's default branch",
|
|
179
|
+
helpValue: "<branch>",
|
|
180
|
+
// oclif's `dependsOn` wants every listed flag, and these two exclude each
|
|
181
|
+
// other; `some` is its spelling of "at least one of".
|
|
182
|
+
relationships: [{ type: "some", flags: ["create-branch", "generate-branch"] }],
|
|
183
|
+
}),
|
|
184
|
+
latest: Flags.boolean({
|
|
185
|
+
description:
|
|
186
|
+
"Make the branch being worked from match upstream's, after listing any local work that would be discarded",
|
|
187
|
+
default: false,
|
|
188
|
+
}),
|
|
127
189
|
};
|
|
128
190
|
|
|
129
191
|
async run(): Promise<void> {
|
|
@@ -175,9 +237,12 @@ export default class RepoLink extends BaseCommand {
|
|
|
175
237
|
? this.planLinkToPath(existingCheckout)
|
|
176
238
|
: this.planClone(name, url, isGlobal, dryRun);
|
|
177
239
|
|
|
240
|
+
const request = branchRequestFrom(flags);
|
|
241
|
+
|
|
178
242
|
if (dryRun) {
|
|
179
243
|
blankLine();
|
|
180
244
|
dryRunNotice(`would link '${name}' to ${plan.directory}`);
|
|
245
|
+
for (const line of describeBranchRequest(request)) dryRunNotice(line);
|
|
181
246
|
dryRunNotice(
|
|
182
247
|
`would record it in ${isGlobal ? "the machine-wide" : "this project's"} links map`
|
|
183
248
|
);
|
|
@@ -185,6 +250,19 @@ export default class RepoLink extends BaseCommand {
|
|
|
185
250
|
return;
|
|
186
251
|
}
|
|
187
252
|
|
|
253
|
+
// Branch work happens before the link is recorded, so a step git refuses
|
|
254
|
+
// leaves no link pointing at a checkout in a state nobody asked for.
|
|
255
|
+
if (hasBranchWork(request)) {
|
|
256
|
+
if (isGlobal) {
|
|
257
|
+
warning(
|
|
258
|
+
`This checkout is SHARED by every project on this machine that links '${name}' ` +
|
|
259
|
+
`with --global.\n` +
|
|
260
|
+
`Changing its branch changes what all of them build from.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
plan.notes.push(...(await this.applyBranchRequest(plan.directory, request, flags.yes)));
|
|
264
|
+
}
|
|
265
|
+
|
|
188
266
|
const map = isGlobal ? readGlobalLinks() : readProjectLinks(sousDir);
|
|
189
267
|
const previous = map.links[name];
|
|
190
268
|
map.links[name] = {
|
|
@@ -204,9 +282,11 @@ export default class RepoLink extends BaseCommand {
|
|
|
204
282
|
for (const line of plan.notes) log(` ${line}`);
|
|
205
283
|
if (plan.notes.length > 0) blankLine();
|
|
206
284
|
|
|
285
|
+
const branch = isGitCheckout(plan.directory) ? currentBranch(plan.directory) : undefined;
|
|
207
286
|
showVariables({
|
|
208
287
|
Repository: name,
|
|
209
288
|
Checkout: plan.directory,
|
|
289
|
+
...(branch === undefined ? {} : { Branch: branch }),
|
|
210
290
|
"Recorded in": linksPath,
|
|
211
291
|
});
|
|
212
292
|
|
|
@@ -215,6 +295,10 @@ export default class RepoLink extends BaseCommand {
|
|
|
215
295
|
log(` This replaces an earlier link to ${previous.path}, which is untouched.`);
|
|
216
296
|
}
|
|
217
297
|
|
|
298
|
+
// A checkout sous has just cloned is as current as upstream by definition;
|
|
299
|
+
// any other one may be days or months old, so say how it compares.
|
|
300
|
+
if (plan.kind !== "cloned") this.reportUpstream(plan.directory);
|
|
301
|
+
|
|
218
302
|
warning(
|
|
219
303
|
`The repository '${name}' is now LINKED.\n` +
|
|
220
304
|
`Its recipes are read from the checkout above, so versions, the lockfile\n` +
|
|
@@ -227,6 +311,227 @@ export default class RepoLink extends BaseCommand {
|
|
|
227
311
|
footer();
|
|
228
312
|
}
|
|
229
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Carries out the branch flags on the checkout, in order: `--latest` first
|
|
316
|
+
* (which switches to the branch being worked from and makes it match
|
|
317
|
+
* upstream's), then `--branch` when `--latest` did not already switch to it,
|
|
318
|
+
* then the new branch. Git decides whether each step may happen; the one
|
|
319
|
+
* thing checked here is what `--latest` would discard, because making a
|
|
320
|
+
* branch match upstream discards work without git warning about it.
|
|
321
|
+
*
|
|
322
|
+
* @param directory - The checkout.
|
|
323
|
+
* @param request - What the flags asked for.
|
|
324
|
+
* @param yes - The confirmation flag, which answers the discard question.
|
|
325
|
+
* @returns Lines describing what was done, for the notes block.
|
|
326
|
+
*/
|
|
327
|
+
private async applyBranchRequest(
|
|
328
|
+
directory: string,
|
|
329
|
+
request: BranchRequest,
|
|
330
|
+
yes: boolean
|
|
331
|
+
): Promise<string[]> {
|
|
332
|
+
if (!isGitCheckout(directory)) {
|
|
333
|
+
throw new ConfigError(
|
|
334
|
+
`${directory} is not a git checkout, so it has no branches to switch or create.\n` +
|
|
335
|
+
` Link it without the branch flags, or turn it into a git repository first.`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const name of [request.switchTo, request.create, request.from]) {
|
|
340
|
+
if (name !== undefined) assertBranchName(directory, name);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const hasUpstream = remoteUrlOf(directory) !== undefined;
|
|
344
|
+
const needsDefault =
|
|
345
|
+
(request.create !== undefined && request.from === undefined) ||
|
|
346
|
+
(request.latest && request.switchTo === undefined && request.from === undefined);
|
|
347
|
+
const fallback = needsDefault ? this.requireDefaultBranch(directory) : undefined;
|
|
348
|
+
const notes: string[] = [];
|
|
349
|
+
|
|
350
|
+
if (request.latest) {
|
|
351
|
+
const target = (request.switchTo ?? request.from ?? fallback)!;
|
|
352
|
+
if (!hasUpstream) {
|
|
353
|
+
throw new ConfigError(
|
|
354
|
+
`--latest makes '${target}' match upstream's, and this checkout has no ` +
|
|
355
|
+
`'${UPSTREAM_REMOTE}' remote to be upstream.\n` +
|
|
356
|
+
` Add one with 'git remote add ${UPSTREAM_REMOTE} <url>' in ${directory}, or ` +
|
|
357
|
+
`link without --latest.`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
fetchBranch(directory, target);
|
|
361
|
+
const work = discardableWork(directory, target);
|
|
362
|
+
await this.confirmDiscard(target, work, yes);
|
|
363
|
+
resetBranchToUpstream(directory, target);
|
|
364
|
+
notes.push(`Made the branch '${target}' match ${UPSTREAM_REMOTE}/${target}.`);
|
|
365
|
+
} else if (request.switchTo !== undefined) {
|
|
366
|
+
// A branch that exists locally is switched to as it is; one that does not
|
|
367
|
+
// is fetched first, since a single-branch clone cannot see it otherwise.
|
|
368
|
+
if (hasUpstream && !localBranchExists(directory, request.switchTo)) {
|
|
369
|
+
fetchBranch(directory, request.switchTo);
|
|
370
|
+
}
|
|
371
|
+
switchBranch(directory, request.switchTo);
|
|
372
|
+
notes.push(`Switched to the branch '${request.switchTo}'.`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (request.create !== undefined) {
|
|
376
|
+
const base = (request.from ?? fallback)!;
|
|
377
|
+
let startPoint = base;
|
|
378
|
+
if (hasUpstream) {
|
|
379
|
+
// With --latest the base was fetched a moment ago.
|
|
380
|
+
if (!request.latest) fetchBranch(directory, base);
|
|
381
|
+
startPoint = `${UPSTREAM_REMOTE}/${base}`;
|
|
382
|
+
}
|
|
383
|
+
createBranch(directory, request.create, startPoint);
|
|
384
|
+
notes.push(
|
|
385
|
+
request.generated
|
|
386
|
+
? `Created the branch '${request.create}' (a generated name) from ${startPoint}, ` +
|
|
387
|
+
`and switched to it.`
|
|
388
|
+
: `Created the branch '${request.create}' from ${startPoint}, and switched to it.`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return notes;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The upstream default branch, or a ConfigError saying it could not be
|
|
397
|
+
* worked out and which flag names a branch instead.
|
|
398
|
+
*
|
|
399
|
+
* @param directory - The checkout.
|
|
400
|
+
*/
|
|
401
|
+
private requireDefaultBranch(directory: string): string {
|
|
402
|
+
const found = defaultBranch(directory);
|
|
403
|
+
if (found !== undefined) return found;
|
|
404
|
+
throw new ConfigError(
|
|
405
|
+
`Could not work out the default branch of the repository checked out at ${directory}.\n` +
|
|
406
|
+
` git records it as '${UPSTREAM_REMOTE}/HEAD' when it clones, and this checkout ` +
|
|
407
|
+
`has no such record, nor could '${UPSTREAM_REMOTE}' be asked for it.\n` +
|
|
408
|
+
` Name the branch to work from with --from, or with --branch.`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Lists what `--latest` would discard and asks once before going on. Nothing
|
|
414
|
+
* is asked when there is nothing to discard, and the confirmation flag
|
|
415
|
+
* answers the question ahead of time.
|
|
416
|
+
*
|
|
417
|
+
* @param branch - The branch being made to match upstream's.
|
|
418
|
+
* @param work - What would be discarded.
|
|
419
|
+
* @param yes - The confirmation flag.
|
|
420
|
+
*/
|
|
421
|
+
private async confirmDiscard(
|
|
422
|
+
branch: string,
|
|
423
|
+
work: DiscardableWork,
|
|
424
|
+
yes: boolean
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
if (work.uncommitted.length === 0 && work.localCommits.length === 0) return;
|
|
427
|
+
|
|
428
|
+
blankLine();
|
|
429
|
+
paragraph(
|
|
430
|
+
`Making '${branch}' match ${UPSTREAM_REMOTE}/${branch} discards the local work below.`,
|
|
431
|
+
{ indent: 2 }
|
|
432
|
+
);
|
|
433
|
+
if (work.uncommitted.length > 0) {
|
|
434
|
+
blankLine();
|
|
435
|
+
showVariable("Uncommitted changes", work.uncommitted.length);
|
|
436
|
+
for (const line of work.uncommitted) log(` ${BULLET} ${line}`);
|
|
437
|
+
}
|
|
438
|
+
if (work.localCommits.length > 0) {
|
|
439
|
+
blankLine();
|
|
440
|
+
showVariable(`Commits ${UPSTREAM_REMOTE} does not have`, work.localCommits.length);
|
|
441
|
+
for (const line of work.localCommits) log(` ${BULLET} ${line}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (yes) return;
|
|
445
|
+
|
|
446
|
+
if (!isInteractive()) {
|
|
447
|
+
throw nonInteractiveError({
|
|
448
|
+
prompt: `whether to discard the local work on '${branch}' listed above`,
|
|
449
|
+
remedy:
|
|
450
|
+
"pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to discard it " +
|
|
451
|
+
"without being asked.",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const proceed = await askYesNo("Discard it?");
|
|
456
|
+
if (!proceed) {
|
|
457
|
+
throw new ConfigError(
|
|
458
|
+
`Nothing was changed: the local work on '${branch}' was kept.\n` +
|
|
459
|
+
` The checkout is on the branch it was on before, and no link was recorded.`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Says how a checkout that was already on disk compares with upstream. A
|
|
466
|
+
* short fetch comes first; it updates only the remote-tracking refs, never
|
|
467
|
+
* the user's files or branches. When upstream cannot be reached, the link
|
|
468
|
+
* still stands and the warning says since when the checkout may have
|
|
469
|
+
* diverged.
|
|
470
|
+
*
|
|
471
|
+
* @param directory - The checkout.
|
|
472
|
+
*/
|
|
473
|
+
private reportUpstream(directory: string): void {
|
|
474
|
+
if (!isGitCheckout(directory)) return;
|
|
475
|
+
|
|
476
|
+
blankLine();
|
|
477
|
+
if (remoteUrlOf(directory) === undefined) {
|
|
478
|
+
note(
|
|
479
|
+
`The checkout has no '${UPSTREAM_REMOTE}' remote, so there is no upstream to ` +
|
|
480
|
+
`compare it with.`,
|
|
481
|
+
{ indent: 2 }
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const fetched = tryFetchUpstream(directory);
|
|
487
|
+
const branch = defaultBranch(directory);
|
|
488
|
+
|
|
489
|
+
if (!fetched.ok) {
|
|
490
|
+
const since = lastFetchedAt(directory, branch);
|
|
491
|
+
warning(
|
|
492
|
+
`Could not reach upstream; the checkout may have diverged since ` +
|
|
493
|
+
(since === undefined
|
|
494
|
+
? `it was last fetched, and git has no record of when that was.`
|
|
495
|
+
: `${formatWhen(since)}, when it was last fetched.`) +
|
|
496
|
+
`\n\nGit said:\n${fetched.reason}\n\n` +
|
|
497
|
+
`The link was recorded all the same, and nothing in the checkout was changed.`
|
|
498
|
+
);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (branch === undefined) {
|
|
503
|
+
note(
|
|
504
|
+
`Upstream was fetched, but its default branch could not be worked out, so there ` +
|
|
505
|
+
`is nothing to compare the checkout with.`,
|
|
506
|
+
{ indent: 2 }
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const comparison = compareWithUpstream(directory, branch);
|
|
512
|
+
const upstream = `${UPSTREAM_REMOTE}/${branch}`;
|
|
513
|
+
const yesNo = (value: boolean | undefined) =>
|
|
514
|
+
value === undefined ? "unknown" : value ? "yes" : "no";
|
|
515
|
+
|
|
516
|
+
// The branch is already in the summary above; a detached HEAD has none
|
|
517
|
+
// there, so it is named here instead.
|
|
518
|
+
showVariables({
|
|
519
|
+
...(comparison.branch === undefined
|
|
520
|
+
? { "Checked out": `no branch; HEAD is detached at ${comparison.commit ?? "an unknown commit"}` }
|
|
521
|
+
: {}),
|
|
522
|
+
"Compared with": upstream,
|
|
523
|
+
[`Merged into ${upstream}`]: yesNo(comparison.merged),
|
|
524
|
+
[`Behind ${upstream}`]:
|
|
525
|
+
comparison.behind === undefined
|
|
526
|
+
? "unknown"
|
|
527
|
+
: `${comparison.behind} ${comparison.behind === 1 ? "commit" : "commits"}`,
|
|
528
|
+
});
|
|
529
|
+
blankLine();
|
|
530
|
+
note(`Upstream was fetched just now; nothing in the checkout was changed.`, {
|
|
531
|
+
indent: 2,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
230
535
|
/**
|
|
231
536
|
* The checkout a REPO argument names outright, as an absolute path, or
|
|
232
537
|
* undefined when the argument is a short name or a URL instead.
|
|
@@ -381,6 +686,7 @@ export default class RepoLink extends BaseCommand {
|
|
|
381
686
|
return {
|
|
382
687
|
directory,
|
|
383
688
|
origin: "path",
|
|
689
|
+
kind: "path",
|
|
384
690
|
notes: [`Linked the checkout already at ${directory}.`],
|
|
385
691
|
};
|
|
386
692
|
}
|
|
@@ -429,6 +735,7 @@ export default class RepoLink extends BaseCommand {
|
|
|
429
735
|
return {
|
|
430
736
|
directory,
|
|
431
737
|
origin: "clone",
|
|
738
|
+
kind: "reused",
|
|
432
739
|
notes: [
|
|
433
740
|
`Reused the checkout already at ${directory}; nothing was cloned.`,
|
|
434
741
|
],
|
|
@@ -439,6 +746,7 @@ export default class RepoLink extends BaseCommand {
|
|
|
439
746
|
return {
|
|
440
747
|
directory,
|
|
441
748
|
origin: "clone",
|
|
749
|
+
kind: "cloned",
|
|
442
750
|
notes: [`Would clone ${url} into ${directory}.`],
|
|
443
751
|
};
|
|
444
752
|
}
|
|
@@ -468,7 +776,7 @@ export default class RepoLink extends BaseCommand {
|
|
|
468
776
|
);
|
|
469
777
|
}
|
|
470
778
|
|
|
471
|
-
return { directory, origin: "clone", notes };
|
|
779
|
+
return { directory, origin: "clone", kind: "cloned", notes };
|
|
472
780
|
}
|
|
473
781
|
}
|
|
474
782
|
|
|
@@ -495,6 +803,97 @@ type LinkPlan = {
|
|
|
495
803
|
directory: string;
|
|
496
804
|
/** Whether sous cloned it or was pointed at it. */
|
|
497
805
|
origin: LinkOrigin;
|
|
806
|
+
/**
|
|
807
|
+
* What this run did to get the checkout: cloned it just now, reused a clone
|
|
808
|
+
* already in place, or was given the path of one.
|
|
809
|
+
*/
|
|
810
|
+
kind: "cloned" | "reused" | "path";
|
|
498
811
|
/** Lines describing what happened, printed before the summary. */
|
|
499
812
|
notes: string[];
|
|
500
813
|
};
|
|
814
|
+
|
|
815
|
+
/** What the branch flags asked for, with a generated name already chosen. */
|
|
816
|
+
type BranchRequest = {
|
|
817
|
+
/** `--branch`: the existing branch to switch to. */
|
|
818
|
+
switchTo?: string;
|
|
819
|
+
/** `--create-branch` or `--generate-branch`: the branch to create. */
|
|
820
|
+
create?: string;
|
|
821
|
+
/** True when the name to create was generated rather than typed. */
|
|
822
|
+
generated: boolean;
|
|
823
|
+
/** `--from`: the base of the new branch; the default branch when unset. */
|
|
824
|
+
from?: string;
|
|
825
|
+
/** `--latest`: make the branch being worked from match upstream's. */
|
|
826
|
+
latest: boolean;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Reads the branch flags into one request. The generated name is chosen here,
|
|
831
|
+
* once, so the dry run and the real run would print the same thing.
|
|
832
|
+
*
|
|
833
|
+
* @param flags - The parsed flags.
|
|
834
|
+
*/
|
|
835
|
+
function branchRequestFrom(flags: {
|
|
836
|
+
branch?: string;
|
|
837
|
+
"create-branch"?: string;
|
|
838
|
+
"generate-branch"?: boolean;
|
|
839
|
+
from?: string;
|
|
840
|
+
latest: boolean;
|
|
841
|
+
}): BranchRequest {
|
|
842
|
+
const generated = flags["generate-branch"] === true;
|
|
843
|
+
const create = generated ? generatedBranchName() : flags["create-branch"];
|
|
844
|
+
return {
|
|
845
|
+
...(flags.branch === undefined ? {} : { switchTo: flags.branch }),
|
|
846
|
+
...(create === undefined ? {} : { create }),
|
|
847
|
+
generated,
|
|
848
|
+
...(flags.from === undefined ? {} : { from: flags.from }),
|
|
849
|
+
latest: flags.latest,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** True when the request asks for anything to be done to the checkout. */
|
|
854
|
+
function hasBranchWork(request: BranchRequest): boolean {
|
|
855
|
+
return request.switchTo !== undefined || request.create !== undefined || request.latest;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* What a dry run says the branch flags would do, one line per step, in the
|
|
860
|
+
* order the real run takes them.
|
|
861
|
+
*
|
|
862
|
+
* @param request - The branch request.
|
|
863
|
+
*/
|
|
864
|
+
function describeBranchRequest(request: BranchRequest): string[] {
|
|
865
|
+
const lines: string[] = [];
|
|
866
|
+
const workingFrom = request.switchTo ?? request.from ?? "the default branch";
|
|
867
|
+
const quoted = (name: string) => (name === "the default branch" ? name : `'${name}'`);
|
|
868
|
+
|
|
869
|
+
if (request.latest) {
|
|
870
|
+
lines.push(
|
|
871
|
+
`would fetch ${quoted(workingFrom)}, list any local work on it that would be ` +
|
|
872
|
+
`discarded, and make it match upstream's`
|
|
873
|
+
);
|
|
874
|
+
} else if (request.switchTo !== undefined) {
|
|
875
|
+
lines.push(`would switch the checkout to the branch '${request.switchTo}'`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (request.create !== undefined) {
|
|
879
|
+
lines.push(
|
|
880
|
+
`would create the branch '${request.create}' from upstream's ` +
|
|
881
|
+
`${quoted(request.from ?? "the default branch")} and switch to it`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return lines;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Renders a moment as `YYYY-MM-DD HH:MM` in local time, which is what a person
|
|
889
|
+
* reads a "since when" as.
|
|
890
|
+
*
|
|
891
|
+
* @param when - The moment to render.
|
|
892
|
+
*/
|
|
893
|
+
function formatWhen(when: Date): string {
|
|
894
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
895
|
+
return (
|
|
896
|
+
`${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} ` +
|
|
897
|
+
`${pad(when.getHours())}:${pad(when.getMinutes())}`
|
|
898
|
+
);
|
|
899
|
+
}
|