@sous-io/sous 0.2.37 → 1.0.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/README.md +14 -256
- package/dist/bin.js +11 -0
- package/dist/sous.js +1093 -0
- package/docs/markdown/README.md +5 -4
- package/docs/markdown/_sidebar.md +21 -1
- package/docs/markdown/commands.md +294 -431
- package/docs/markdown/config-discovery.md +25 -14
- package/docs/markdown/config-inspection.md +53 -31
- package/docs/markdown/config-layers.md +168 -66
- package/docs/markdown/config-variables.md +44 -11
- package/docs/markdown/configuration.md +104 -17
- package/docs/markdown/design-principles.md +4 -1
- package/docs/markdown/repositories-authoring.md +30 -0
- package/docs/markdown/repositories-consuming.md +72 -14
- package/docs/markdown/repositories-file-formats.md +31 -13
- package/docs/markdown/repositories-providers.md +48 -57
- package/docs/markdown/repositories-quickstart.md +3 -2
- package/docs/markdown/repositories-troubleshooting.md +27 -1
- package/docs/markdown/repositories-variables.md +6 -1
- package/docs/markdown/repositories.md +18 -8
- package/package.json +48 -107
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
- package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +351 -132
- package/bin/run.js +0 -51
- package/src/base-command.ts +0 -457
- package/src/commands/build.ts +0 -181
- package/src/commands/clear.ts +0 -79
- package/src/commands/compile.ts +0 -155
- package/src/commands/config/get.ts +0 -110
- package/src/commands/config/show.ts +0 -32
- package/src/commands/config/validate.ts +0 -53
- package/src/commands/help.ts +0 -46
- package/src/commands/init.ts +0 -289
- package/src/commands/launch.ts +0 -139
- package/src/commands/lock/rebuild.ts +0 -239
- package/src/commands/lock/show.ts +0 -115
- package/src/commands/namespace/list.ts +0 -138
- package/src/commands/namespace/show.ts +0 -130
- package/src/commands/prune.ts +0 -43
- package/src/commands/recipe/list.ts +0 -112
- package/src/commands/recipe/show.ts +0 -439
- package/src/commands/repo/add.ts +0 -145
- package/src/commands/repo/contribute.ts +0 -604
- package/src/commands/repo/gc.ts +0 -173
- package/src/commands/repo/init.ts +0 -136
- package/src/commands/repo/link.ts +0 -899
- package/src/commands/repo/list.ts +0 -236
- package/src/commands/repo/release.ts +0 -685
- package/src/commands/repo/remove.ts +0 -194
- package/src/commands/repo/search.ts +0 -243
- package/src/commands/repo/submit.ts +0 -341
- package/src/commands/repo/unlink.ts +0 -461
- package/src/commands/subscription/add.ts +0 -286
- package/src/commands/subscription/list.ts +0 -208
- package/src/commands/subscription/remove.ts +0 -182
- package/src/commands/subscription/update.ts +0 -216
- package/src/commands/update.ts +0 -178
- package/src/commands/vars/ask.ts +0 -417
- package/src/commands/vars/index.ts +0 -79
- package/src/commands/vars/list.ts +0 -67
- package/src/commands/vars/show.ts +0 -77
- package/src/config-command.ts +0 -30
- package/src/lib/build-preparation.ts +0 -128
- package/src/lib/build-service.ts +0 -580
- package/src/lib/config-discovery.ts +0 -378
- package/src/lib/config-inspect.ts +0 -145
- package/src/lib/config-kernel.mjs +0 -379
- package/src/lib/config-schema.ts +0 -401
- package/src/lib/env-file.ts +0 -328
- package/src/lib/env-local.ts +0 -212
- package/src/lib/errors.ts +0 -32
- package/src/lib/glob-pattern.ts +0 -24
- package/src/lib/include-resolver.ts +0 -523
- package/src/lib/interactive.ts +0 -165
- package/src/lib/markdown-compiler.ts +0 -947
- package/src/lib/package-info.ts +0 -25
- package/src/lib/pid-service.ts +0 -99
- package/src/lib/project-install.d.mts +0 -44
- package/src/lib/project-install.mjs +0 -234
- package/src/lib/project-scaffold/index.ts +0 -332
- package/src/lib/project-scaffold/templates.ts +0 -240
- package/src/lib/repos/catalog-display.ts +0 -215
- package/src/lib/repos/catalog-inputs.ts +0 -288
- package/src/lib/repos/catalog.ts +0 -842
- package/src/lib/repos/contribute.ts +0 -452
- package/src/lib/repos/core-recipe.ts +0 -105
- package/src/lib/repos/declarations.ts +0 -187
- package/src/lib/repos/defaults.ts +0 -175
- package/src/lib/repos/formats/common.ts +0 -440
- package/src/lib/repos/formats/index-file.ts +0 -253
- package/src/lib/repos/formats/links-map.ts +0 -98
- package/src/lib/repos/formats/lockfile.ts +0 -169
- package/src/lib/repos/formats/patterns.ts +0 -57
- package/src/lib/repos/formats/recipe-manifest.ts +0 -205
- package/src/lib/repos/formats/repo-manifest.ts +0 -97
- package/src/lib/repos/formats/store-entry.ts +0 -84
- package/src/lib/repos/formats/variable-definition.ts +0 -268
- package/src/lib/repos/freshness.ts +0 -264
- package/src/lib/repos/git-clone.ts +0 -850
- package/src/lib/repos/identity.ts +0 -89
- package/src/lib/repos/index.ts +0 -57
- package/src/lib/repos/links.ts +0 -356
- package/src/lib/repos/load-manifest.ts +0 -237
- package/src/lib/repos/lock-service.ts +0 -454
- package/src/lib/repos/locked-namespace-resolver.ts +0 -232
- package/src/lib/repos/locked-recipe-lookup.ts +0 -123
- package/src/lib/repos/locked-recipes.ts +0 -254
- package/src/lib/repos/managed-layer.ts +0 -422
- package/src/lib/repos/memories-hash-name.ts +0 -33
- package/src/lib/repos/namespace-resolver.ts +0 -527
- package/src/lib/repos/providers/base.ts +0 -268
- package/src/lib/repos/providers/git.ts +0 -245
- package/src/lib/repos/providers/github.ts +0 -589
- package/src/lib/repos/providers/gitlab.ts +0 -332
- package/src/lib/repos/providers/http.ts +0 -128
- package/src/lib/repos/providers/index-cache.ts +0 -421
- package/src/lib/repos/providers/index.ts +0 -106
- package/src/lib/repos/providers/local.ts +0 -410
- package/src/lib/repos/providers/provider.ts +0 -615
- package/src/lib/repos/recipe-config-layers.ts +0 -316
- package/src/lib/repos/recipe-key-matcher.ts +0 -62
- package/src/lib/repos/recipe-memories.ts +0 -178
- package/src/lib/repos/recipe-targets.ts +0 -236
- package/src/lib/repos/ref-search.ts +0 -46
- package/src/lib/repos/release/bump.ts +0 -161
- package/src/lib/repos/release/changelog.ts +0 -461
- package/src/lib/repos/release/git-state.ts +0 -460
- package/src/lib/repos/release/index-builder.ts +0 -1108
- package/src/lib/repos/release/index.ts +0 -19
- package/src/lib/repos/release/plan.ts +0 -521
- package/src/lib/repos/release/settle.ts +0 -154
- package/src/lib/repos/release/submissions.ts +0 -214
- package/src/lib/repos/release/submit-checkout.ts +0 -290
- package/src/lib/repos/release/submit-questions.ts +0 -153
- package/src/lib/repos/release/submit-service.ts +0 -898
- package/src/lib/repos/release/tags.ts +0 -239
- package/src/lib/repos/release/validate.ts +0 -463
- package/src/lib/repos/resolver.ts +0 -1028
- package/src/lib/repos/scaffold/index.ts +0 -245
- package/src/lib/repos/scaffold/templates.ts +0 -517
- package/src/lib/repos/seed.ts +0 -473
- package/src/lib/repos/store/contract.ts +0 -64
- package/src/lib/repos/store/hash.ts +0 -191
- package/src/lib/repos/store/recipe-store.ts +0 -599
- package/src/lib/repos/store/settings.ts +0 -58
- package/src/lib/repos/subscription-service.ts +0 -3859
- package/src/lib/repos/trust.ts +0 -447
- package/src/lib/repos/update-plan.ts +0 -235
- package/src/lib/self-update/index.ts +0 -14
- package/src/lib/self-update/installs.ts +0 -413
- package/src/lib/self-update/managers.ts +0 -126
- package/src/lib/self-update/plan.ts +0 -315
- package/src/lib/self-update/registry.ts +0 -99
- package/src/lib/self-update/run.ts +0 -506
- package/src/lib/self-update/versions.ts +0 -243
- package/src/lib/settings.ts +0 -1107
- package/src/lib/sous-home.ts +0 -104
- package/src/lib/state.ts +0 -167
- package/src/lib/vars/answers.ts +0 -192
- package/src/lib/vars/ask.ts +0 -1152
- package/src/lib/vars/definition-source.ts +0 -261
- package/src/lib/vars/display.ts +0 -231
- package/src/lib/vars/index.ts +0 -20
- package/src/lib/vars/ladder.ts +0 -282
- package/src/lib/vars/mappings.ts +0 -260
- package/src/lib/vars/names.ts +0 -94
- package/src/lib/vars/preanswers.ts +0 -395
- package/src/lib/vars/question-plan.ts +0 -241
- package/src/lib/vars/report.ts +0 -228
- package/src/lib/vars/safe-regex.ts +0 -235
- package/src/lib/vars/secrets.ts +0 -131
- package/src/lib/vars/validate.ts +0 -312
- package/src/lib/version-report.ts +0 -90
- package/src/lib/watch-loop.ts +0 -148
- package/src/lib/watch-service.ts +0 -116
- package/src/services/ref-resolver/container.ts +0 -107
- package/src/services/ref-resolver/dependency.ts +0 -60
- package/src/services/ref-resolver/format.ts +0 -197
- package/src/services/ref-resolver/glob.ts +0 -115
- package/src/services/ref-resolver/hash-names.ts +0 -155
- package/src/services/ref-resolver/index.ts +0 -55
- package/src/services/ref-resolver/injectable.ts +0 -36
- package/src/services/ref-resolver/location.ts +0 -62
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
- package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
- package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
- package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
- package/src/services/ref-resolver/named-refs.ts +0 -144
- package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
- package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
- package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
- package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
- package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
- package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
- package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
- package/src/services/ref-resolver/parts.ts +0 -115
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
- package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
- package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
- package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
- package/src/services/ref-resolver/ref-helpers.ts +0 -62
- package/src/services/ref-resolver/ref-picker-service.ts +0 -204
- package/src/services/ref-resolver/ref-report.ts +0 -84
- package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
- package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
- package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
- package/src/services/ref-resolver/source.ts +0 -48
- package/src/services/ref-resolver/tokens.ts +0 -25
- package/src/services/ref-resolver/types.ts +0 -155
- package/src/templating/filters/bullet-list.ts +0 -9
- package/src/templating/filters/index.ts +0 -8
- package/src/templating/init-liquid-engine.ts +0 -139
- package/src/templating/lib/glob-files.ts +0 -74
- package/src/templating/lib/import-export.ts +0 -32
- package/src/templating/lib/secret-scope.ts +0 -41
- package/src/templating/lib/tag-args.ts +0 -19
- package/src/templating/tags/exportScalarVarsJs.ts +0 -46
- package/src/templating/tags/getFiles.ts +0 -89
- package/src/templating/tags/index.ts +0 -14
- package/src/templating/tags/listFiles.ts +0 -54
- package/src/templating/tags/showVars.ts +0 -28
- package/src/utils/choice-prompt.ts +0 -143
- package/src/utils/command-errors.ts +0 -186
- package/src/utils/command-help.ts +0 -45
- package/src/utils/confirm-prompt.ts +0 -110
- package/src/utils/flags.ts +0 -177
- package/src/utils/formatting.ts +0 -849
- package/src/utils/prompts.ts +0 -53
- package/src/utils/sous-directory.ts +0 -245
- package/src/utils/table.ts +0 -603
- package/src/utils/value-prompt.ts +0 -119
|
@@ -1,850 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The thin git layer used by `sous repo link`.
|
|
3
|
-
*
|
|
4
|
-
* Linking a repository sometimes means cloning it, and always means asking
|
|
5
|
-
* whether a directory already on disk is the right checkout. Both jobs are done
|
|
6
|
-
* by shelling out to the user's own `git`, rather than by bundling a git
|
|
7
|
-
* implementation: the user's credentials, SSH agent, proxy settings and
|
|
8
|
-
* `insteadOf` rewrites are already configured there, and sous inheriting all of
|
|
9
|
-
* that for free is worth more than any library.
|
|
10
|
-
*
|
|
11
|
-
* Every function takes an optional `runner`, so tests can drive the whole
|
|
12
|
-
* surface without a real git or a network connection.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import fs from "node:fs";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import { spawnSync } from "node:child_process";
|
|
18
|
-
import { ConfigError } from "../errors.js";
|
|
19
|
-
import { LF_CHECKOUT_OPTIONS } from "./store/hash.js";
|
|
20
|
-
|
|
21
|
-
/** What one git invocation produced. */
|
|
22
|
-
export type GitResult = {
|
|
23
|
-
/** The process exit code, or null when it was killed by a signal. */
|
|
24
|
-
status: number | null;
|
|
25
|
-
/** Everything git wrote to standard output, trimmed. */
|
|
26
|
-
stdout: string;
|
|
27
|
-
/** Everything git wrote to standard error, trimmed. */
|
|
28
|
-
stderr: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** Where and for how long one git command may run. */
|
|
32
|
-
export type GitRunOptions = {
|
|
33
|
-
/** The directory git runs in. */
|
|
34
|
-
cwd?: string;
|
|
35
|
-
/**
|
|
36
|
-
* How long git may run, in milliseconds, before it is stopped. A command that
|
|
37
|
-
* runs out of time comes back with a null status and a sentence saying so in
|
|
38
|
-
* `stderr`, never as an exception. Unset means no limit.
|
|
39
|
-
*/
|
|
40
|
-
timeoutMs?: number;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Runs one git command. Swappable so tests never need a real git binary.
|
|
45
|
-
*
|
|
46
|
-
* @param args - The arguments passed to git, without the leading "git".
|
|
47
|
-
* @param options - Where to run it, and for how long.
|
|
48
|
-
*/
|
|
49
|
-
export type GitRunner = (args: string[], options: GitRunOptions) => GitResult;
|
|
50
|
-
|
|
51
|
-
/** Options shared by every function here. */
|
|
52
|
-
export type GitOptions = {
|
|
53
|
-
/** The git runner to use. Defaults to the real `git` on PATH. */
|
|
54
|
-
runner?: GitRunner;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The default runner: invokes the real `git` on PATH and captures its output.
|
|
59
|
-
*
|
|
60
|
-
* @param args - The arguments passed to git, without the leading "git".
|
|
61
|
-
* @param options - Where to run it.
|
|
62
|
-
*/
|
|
63
|
-
export const runGit: GitRunner = (args, options = {}) => {
|
|
64
|
-
const result = spawnSync("git", args, {
|
|
65
|
-
cwd: options.cwd,
|
|
66
|
-
encoding: "utf8",
|
|
67
|
-
// A clone must never stop to ask for a password; a prompt in a
|
|
68
|
-
// non-interactive run would hang the command with no explanation.
|
|
69
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
70
|
-
timeout: options.timeoutMs,
|
|
71
|
-
killSignal: "SIGKILL",
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") {
|
|
75
|
-
const seconds = Math.round((options.timeoutMs ?? 0) / 1000);
|
|
76
|
-
const partial = (result.stderr ?? "").trim();
|
|
77
|
-
return {
|
|
78
|
-
status: null,
|
|
79
|
-
stdout: (result.stdout ?? "").trim(),
|
|
80
|
-
stderr:
|
|
81
|
-
`git did not finish within ${seconds} seconds and was stopped.` +
|
|
82
|
-
(partial.length > 0 ? `\n${partial}` : ""),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (result.error !== undefined) {
|
|
87
|
-
const reason = (result.error as NodeJS.ErrnoException).code === "ENOENT"
|
|
88
|
-
? "git is not installed, or is not on your PATH"
|
|
89
|
-
: result.error.message;
|
|
90
|
-
throw new ConfigError(
|
|
91
|
-
`Could not run git.\n` +
|
|
92
|
-
` ${reason}.\n` +
|
|
93
|
-
` sous uses your own git so that your credentials and configuration apply; ` +
|
|
94
|
-
`install git and try again.`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
status: result.status,
|
|
100
|
-
stdout: (result.stdout ?? "").trim(),
|
|
101
|
-
stderr: (result.stderr ?? "").trim(),
|
|
102
|
-
};
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Runs git and throws a ConfigError on failure: a first line naming the step
|
|
107
|
-
* that failed, then the command, then git's own message, line for line. Git is
|
|
108
|
-
* the authority on what it will and will not do to a checkout, so its refusal
|
|
109
|
-
* is passed through rather than paraphrased.
|
|
110
|
-
*/
|
|
111
|
-
function runGitOrThrow(
|
|
112
|
-
args: string[],
|
|
113
|
-
options: { cwd?: string; runner?: GitRunner; what: string; timeoutMs?: number }
|
|
114
|
-
): GitResult {
|
|
115
|
-
const runner = options.runner ?? runGit;
|
|
116
|
-
const result = runner(args, { cwd: options.cwd, timeoutMs: options.timeoutMs });
|
|
117
|
-
if (result.status !== 0) {
|
|
118
|
-
const detail = gitMessage(result);
|
|
119
|
-
const said =
|
|
120
|
-
detail.length > 0
|
|
121
|
-
? ` git said:\n${detail
|
|
122
|
-
.split("\n")
|
|
123
|
-
.map((line) => ` ${line}`)
|
|
124
|
-
.join("\n")}\n`
|
|
125
|
-
: "";
|
|
126
|
-
throw new ConfigError(
|
|
127
|
-
`${options.what} failed.\n` +
|
|
128
|
-
` Command: git ${args.join(" ")}\n` +
|
|
129
|
-
said +
|
|
130
|
-
` Fix the problem git reported, then run the command again.`
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
return result;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** What git said about a result: its error output, or its standard output when that is all. */
|
|
137
|
-
function gitMessage(result: GitResult): string {
|
|
138
|
-
return result.stderr.length > 0 ? result.stderr : result.stdout;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* True when the directory is inside a git working tree whose root is that same
|
|
143
|
-
* directory. A subdirectory of a checkout is deliberately not a checkout here:
|
|
144
|
-
* linking half a repository would silently produce a repo with no manifest at
|
|
145
|
-
* its root.
|
|
146
|
-
*
|
|
147
|
-
* @param directory - The directory to test.
|
|
148
|
-
* @param options - The git runner to use.
|
|
149
|
-
*/
|
|
150
|
-
export function isGitCheckout(directory: string, options: GitOptions = {}): boolean {
|
|
151
|
-
if (!directoryExists(directory)) return false;
|
|
152
|
-
|
|
153
|
-
const runner = options.runner ?? runGit;
|
|
154
|
-
const result = runner(["rev-parse", "--show-toplevel"], { cwd: directory });
|
|
155
|
-
if (result.status !== 0) return false;
|
|
156
|
-
|
|
157
|
-
return samePath(result.stdout, directory);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The URL of a checkout's `origin` remote, or undefined when it has none (a
|
|
162
|
-
* repository created locally with `git init` has no remote until one is added).
|
|
163
|
-
*
|
|
164
|
-
* @param directory - The checkout to inspect.
|
|
165
|
-
* @param options - The git runner to use.
|
|
166
|
-
*/
|
|
167
|
-
export function remoteUrlOf(directory: string, options: GitOptions = {}): string | undefined {
|
|
168
|
-
const runner = options.runner ?? runGit;
|
|
169
|
-
const result = runner(["remote", "get-url", "origin"], { cwd: directory });
|
|
170
|
-
if (result.status !== 0 || result.stdout.length === 0) return undefined;
|
|
171
|
-
return result.stdout;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** What a clone actually did. */
|
|
175
|
-
export type CloneResult = {
|
|
176
|
-
/** The depth git was finally asked for; 0 means the full history. */
|
|
177
|
-
depth: number;
|
|
178
|
-
/** True when a shallow clone was refused and the full history was fetched instead. */
|
|
179
|
-
fellBackToFullClone: boolean;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Clones a repository into a directory that does not yet exist, creating its
|
|
184
|
-
* parents. A shallow clone is the default for a link, because a linked checkout
|
|
185
|
-
* exists to be read and edited, not to carry the project's whole history; pass
|
|
186
|
-
* `depth: 0` to ask for the full history outright.
|
|
187
|
-
*
|
|
188
|
-
* Not every remote will serve a shallow clone (`file://` transports and some
|
|
189
|
-
* servers refuse one), so a failed shallow attempt is retried in full rather
|
|
190
|
-
* than reported as a failure. The result says whether that happened, so the
|
|
191
|
-
* caller can tell the user why the clone took longer than they expected.
|
|
192
|
-
*
|
|
193
|
-
* @param url - Where the repository lives.
|
|
194
|
-
* @param destDir - Absolute path the working copy is created at.
|
|
195
|
-
* @param options - Clone depth and the git runner to use.
|
|
196
|
-
*/
|
|
197
|
-
export function cloneRepo(
|
|
198
|
-
url: string,
|
|
199
|
-
destDir: string,
|
|
200
|
-
options: GitOptions & { depth?: number } = {}
|
|
201
|
-
): CloneResult {
|
|
202
|
-
if (fs.existsSync(destDir) && !isEmptyDirectory(destDir)) {
|
|
203
|
-
throw new ConfigError(
|
|
204
|
-
`Cannot clone into ${destDir}: the directory already exists and is not empty.\n` +
|
|
205
|
-
` Move or delete it, or link the checkout that is already there by passing ` +
|
|
206
|
-
`its path to 'sous repo link'.`
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
211
|
-
|
|
212
|
-
const depth = options.depth ?? 1;
|
|
213
|
-
const runner = options.runner ?? runGit;
|
|
214
|
-
|
|
215
|
-
// A linked checkout is hashed when its recipes are released or compiled, so
|
|
216
|
-
// it keeps the committed bytes whatever this machine's line-ending settings are.
|
|
217
|
-
if (depth > 0) {
|
|
218
|
-
const shallow = runner(
|
|
219
|
-
["clone", ...LF_CHECKOUT_OPTIONS, "--depth", String(depth), "--", url, destDir],
|
|
220
|
-
{}
|
|
221
|
-
);
|
|
222
|
-
if (shallow.status === 0) return { depth, fellBackToFullClone: false };
|
|
223
|
-
// git leaves the destination behind on some failures; clear it so the retry
|
|
224
|
-
// is not refused by its own leftovers.
|
|
225
|
-
fs.rmSync(destDir, { recursive: true, force: true });
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
runGitOrThrow(["clone", ...LF_CHECKOUT_OPTIONS, "--", url, destDir], {
|
|
229
|
-
runner: options.runner,
|
|
230
|
-
what: `Cloning ${url}`,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
return { depth: 0, fellBackToFullClone: depth > 0 };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Work in a checkout that exists nowhere else, and would be lost with it. */
|
|
237
|
-
export type UnsavedWork = {
|
|
238
|
-
/** Every changed or untracked path, as `git status --porcelain` prints it. */
|
|
239
|
-
uncommitted: string[];
|
|
240
|
-
/** Every commit on a local branch that no remote has, one line each. */
|
|
241
|
-
unpushed: string[];
|
|
242
|
-
/** Every stash entry, one line each. */
|
|
243
|
-
stashes: string[];
|
|
244
|
-
/**
|
|
245
|
-
* Why git could not be asked, when it could not. Nothing is known about the
|
|
246
|
-
* checkout then, which a caller must treat as possibly holding work.
|
|
247
|
-
*/
|
|
248
|
-
unknown?: string;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* What in a checkout would be lost if it were deleted: uncommitted changes,
|
|
253
|
-
* commits no remote has, and stashes.
|
|
254
|
-
*
|
|
255
|
-
* unsavedWork("/path/to/checkout")
|
|
256
|
-
* // -> { uncommitted: ["M README.md"], unpushed: [], stashes: [] }
|
|
257
|
-
*
|
|
258
|
-
* @param directory - The checkout to inspect.
|
|
259
|
-
* @param options - The git runner to use.
|
|
260
|
-
*/
|
|
261
|
-
export function unsavedWork(directory: string, options: GitOptions = {}): UnsavedWork {
|
|
262
|
-
const runner = options.runner ?? runGit;
|
|
263
|
-
const lines = (text: string): string[] =>
|
|
264
|
-
text
|
|
265
|
-
.split("\n")
|
|
266
|
-
.map((line) => line.trim())
|
|
267
|
-
.filter((line) => line.length > 0);
|
|
268
|
-
|
|
269
|
-
const status = runner(["status", "--porcelain"], { cwd: directory });
|
|
270
|
-
if (status.status !== 0) {
|
|
271
|
-
return {
|
|
272
|
-
uncommitted: [],
|
|
273
|
-
unpushed: [],
|
|
274
|
-
stashes: [],
|
|
275
|
-
unknown:
|
|
276
|
-
`git could not read the checkout at ${directory}` +
|
|
277
|
-
(status.stderr.length > 0 ? `: ${status.stderr}` : "."),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const unpushed = runner(["log", "--branches", "--not", "--remotes", "--oneline"], {
|
|
282
|
-
cwd: directory,
|
|
283
|
-
});
|
|
284
|
-
const stashes = runner(["stash", "list"], { cwd: directory });
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
uncommitted: lines(status.stdout),
|
|
288
|
-
unpushed: unpushed.status === 0 ? lines(unpushed.stdout) : [],
|
|
289
|
-
stashes: stashes.status === 0 ? lines(stashes.stdout) : [],
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* True when an inspection found nothing that would be lost.
|
|
295
|
-
*
|
|
296
|
-
* @param work - What `unsavedWork` found.
|
|
297
|
-
*/
|
|
298
|
-
export function hasNoUnsavedWork(work: UnsavedWork): boolean {
|
|
299
|
-
return (
|
|
300
|
-
work.unknown === undefined &&
|
|
301
|
-
work.uncommitted.length === 0 &&
|
|
302
|
-
work.unpushed.length === 0 &&
|
|
303
|
-
work.stashes.length === 0
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* True when two remote URLs name the same repository, ignoring the differences
|
|
309
|
-
* that never change what is fetched: a `.git` suffix, a trailing slash, the
|
|
310
|
-
* host's letter case, and SCP-style syntax (`git@host:owner/repo`) against URL
|
|
311
|
-
* syntax (`https://host/owner/repo`).
|
|
312
|
-
*
|
|
313
|
-
* @param a - One remote URL.
|
|
314
|
-
* @param b - The other remote URL.
|
|
315
|
-
*/
|
|
316
|
-
export function sameRemote(a: string, b: string): boolean {
|
|
317
|
-
return normalizeRemoteUrl(a) === normalizeRemoteUrl(b);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Reduces a remote URL to `host/path` in lower case, with any `.git` suffix,
|
|
322
|
-
* trailing slash, scheme, port and user info removed, so two spellings of one
|
|
323
|
-
* repository compare equal.
|
|
324
|
-
*
|
|
325
|
-
* @param url - The remote URL to normalize.
|
|
326
|
-
*/
|
|
327
|
-
export function normalizeRemoteUrl(url: string): string {
|
|
328
|
-
let value = url.trim();
|
|
329
|
-
|
|
330
|
-
// SCP-style syntax, which is not a URL: git@github.com:sous-io/sous.git
|
|
331
|
-
const scp = /^(?:[^@/]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(value);
|
|
332
|
-
if (scp !== null) {
|
|
333
|
-
value = `${scp[1]}/${scp[2]}`;
|
|
334
|
-
} else {
|
|
335
|
-
value = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
|
336
|
-
// Strip user info (user@ or user:password@) from an authority.
|
|
337
|
-
value = value.replace(/^[^/]*@/, "");
|
|
338
|
-
// Strip a port from the host.
|
|
339
|
-
value = value.replace(/^([^/]+):\d+/, "$1");
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
value = value.replace(/\/+$/, "");
|
|
343
|
-
value = value.replace(/\.git$/i, "");
|
|
344
|
-
return value.toLowerCase();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** A repository's owner and name, as taken from its URL. */
|
|
348
|
-
export type RepoSlug = {
|
|
349
|
-
/** The owning user or organization, or "repos" when the URL has no owner segment. */
|
|
350
|
-
owner: string;
|
|
351
|
-
/** The repository's own name, with any `.git` suffix removed. */
|
|
352
|
-
name: string;
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Splits a remote URL into the owner and name that decide where a default clone
|
|
357
|
-
* lands (`.sous/repos/<owner>/<name>`). A URL with no owner segment yields the
|
|
358
|
-
* literal owner "repos", so the layout stays two levels deep whatever the URL
|
|
359
|
-
* looked like.
|
|
360
|
-
*
|
|
361
|
-
* @param url - The remote URL to read.
|
|
362
|
-
*/
|
|
363
|
-
export function repoSlugFromUrl(url: string): RepoSlug {
|
|
364
|
-
const normalized = normalizeRemoteUrl(url);
|
|
365
|
-
const segments = normalized.split("/").filter((segment) => segment.length > 0);
|
|
366
|
-
|
|
367
|
-
if (segments.length < 2) {
|
|
368
|
-
throw new ConfigError(
|
|
369
|
-
`Could not work out an owner and a repository name from the URL '${url}'.\n` +
|
|
370
|
-
` A repository URL looks like 'https://github.com/sous-io/sous-recipes'. ` +
|
|
371
|
-
`Pass a path to 'sous repo link' to link a checkout that is already on disk.`
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const name = segments[segments.length - 1]!;
|
|
376
|
-
const owner = segments.length >= 3 ? segments[segments.length - 2]! : "repos";
|
|
377
|
-
return { owner: sanitizeSegment(owner), name: sanitizeSegment(name) };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* The short name a repository is known by when its URL was given on the command
|
|
382
|
-
* line rather than configured: the last segment of the URL, with any `.git`
|
|
383
|
-
* suffix removed.
|
|
384
|
-
*
|
|
385
|
-
* @param url - The remote URL to read.
|
|
386
|
-
*/
|
|
387
|
-
export function repoNameFromUrl(url: string): string {
|
|
388
|
-
return repoSlugFromUrl(url).name;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* True when the string looks like something git can clone rather than a short
|
|
393
|
-
* name: an explicit scheme, SCP-style syntax, or an absolute path.
|
|
394
|
-
*
|
|
395
|
-
* @param value - The string to test.
|
|
396
|
-
*/
|
|
397
|
-
export function looksLikeRepoUrl(value: string): boolean {
|
|
398
|
-
if (value.includes("://")) return true;
|
|
399
|
-
if (/^[^@/\s]+@[^/\s:]+:/.test(value)) return true;
|
|
400
|
-
return value.startsWith("/") || value.startsWith("./") || value.startsWith("../");
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// --- Branches and upstream ----------------------------------------------------------------------
|
|
404
|
-
//
|
|
405
|
-
// Everything below works on the `origin` remote, which is the one a clone
|
|
406
|
-
// creates and the one `remoteUrlOf` reads. Each function either reports a fact
|
|
407
|
-
// or runs exactly one git operation; when git refuses an operation, its own
|
|
408
|
-
// message is passed through under a line naming the step, and nothing here
|
|
409
|
-
// second-guesses it. The one exception is `discardableWork`, which exists
|
|
410
|
-
// because making a branch match upstream discards work without git warning
|
|
411
|
-
// about it.
|
|
412
|
-
|
|
413
|
-
/** The remote every function here reads from and fetches. */
|
|
414
|
-
export const UPSTREAM_REMOTE = "origin";
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* How long the fetch behind the divergence report may take, in milliseconds.
|
|
418
|
-
* It is tight on purpose: the report is a courtesy, and an unreachable host
|
|
419
|
-
* must fall back to a warning rather than hold the link up.
|
|
420
|
-
*/
|
|
421
|
-
export const UPSTREAM_CHECK_TIMEOUT_MS = 10_000;
|
|
422
|
-
|
|
423
|
-
/** What a fetch that is allowed to fail produced. */
|
|
424
|
-
export type FetchOutcome =
|
|
425
|
-
| { ok: true }
|
|
426
|
-
| {
|
|
427
|
-
ok: false;
|
|
428
|
-
/** Git's own explanation, or the sentence saying it ran out of time. */
|
|
429
|
-
reason: string;
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* The branch a checkout has checked out, or undefined when HEAD is detached
|
|
434
|
-
* (a tag or a bare commit is checked out instead of a branch).
|
|
435
|
-
*
|
|
436
|
-
* @param directory - The checkout to inspect.
|
|
437
|
-
* @param options - The git runner to use.
|
|
438
|
-
*/
|
|
439
|
-
export function currentBranch(directory: string, options: GitOptions = {}): string | undefined {
|
|
440
|
-
const runner = options.runner ?? runGit;
|
|
441
|
-
const result = runner(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: directory });
|
|
442
|
-
if (result.status !== 0 || result.stdout.length === 0) return undefined;
|
|
443
|
-
return result.stdout;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* The abbreviated commit HEAD points at, for describing a detached checkout.
|
|
448
|
-
*
|
|
449
|
-
* @param directory - The checkout to inspect.
|
|
450
|
-
* @param options - The git runner to use.
|
|
451
|
-
*/
|
|
452
|
-
export function headCommit(directory: string, options: GitOptions = {}): string | undefined {
|
|
453
|
-
const runner = options.runner ?? runGit;
|
|
454
|
-
const result = runner(["rev-parse", "--short", "HEAD"], { cwd: directory });
|
|
455
|
-
if (result.status !== 0 || result.stdout.length === 0) return undefined;
|
|
456
|
-
return result.stdout;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* The upstream repository's default branch, or undefined when it cannot be
|
|
461
|
-
* worked out. A clone records it as `origin/HEAD`, which is read first and
|
|
462
|
-
* needs no network; a checkout that lacks that record (one made with `git
|
|
463
|
-
* init` and a remote added later, say) is asked about over the network, under
|
|
464
|
-
* the same tight timeout as the upstream check.
|
|
465
|
-
*
|
|
466
|
-
* @param directory - The checkout to inspect.
|
|
467
|
-
* @param options - The git runner to use.
|
|
468
|
-
*/
|
|
469
|
-
export function defaultBranch(directory: string, options: GitOptions = {}): string | undefined {
|
|
470
|
-
const runner = options.runner ?? runGit;
|
|
471
|
-
const prefix = `${UPSTREAM_REMOTE}/`;
|
|
472
|
-
|
|
473
|
-
const local = runner(
|
|
474
|
-
["symbolic-ref", "--quiet", "--short", `refs/remotes/${UPSTREAM_REMOTE}/HEAD`],
|
|
475
|
-
{ cwd: directory }
|
|
476
|
-
);
|
|
477
|
-
if (local.status === 0 && local.stdout.startsWith(prefix)) {
|
|
478
|
-
return local.stdout.slice(prefix.length);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const remote = runner(["ls-remote", "--symref", UPSTREAM_REMOTE, "HEAD"], {
|
|
482
|
-
cwd: directory,
|
|
483
|
-
timeoutMs: UPSTREAM_CHECK_TIMEOUT_MS,
|
|
484
|
-
});
|
|
485
|
-
if (remote.status !== 0) return undefined;
|
|
486
|
-
const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(remote.stdout);
|
|
487
|
-
return match?.[1];
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Fetches from upstream, allowing the fetch to fail. A fetch updates only the
|
|
492
|
-
* remote-tracking refs, never the user's files or branches, which is why the
|
|
493
|
-
* divergence report may run one unasked.
|
|
494
|
-
*
|
|
495
|
-
* @param directory - The checkout to fetch into.
|
|
496
|
-
* @param options - The git runner to use, and how long the fetch may take.
|
|
497
|
-
*/
|
|
498
|
-
export function tryFetchUpstream(
|
|
499
|
-
directory: string,
|
|
500
|
-
options: GitOptions & { timeoutMs?: number } = {}
|
|
501
|
-
): FetchOutcome {
|
|
502
|
-
const runner = options.runner ?? runGit;
|
|
503
|
-
const result = runner(["fetch", "--quiet", UPSTREAM_REMOTE], {
|
|
504
|
-
cwd: directory,
|
|
505
|
-
timeoutMs: options.timeoutMs ?? UPSTREAM_CHECK_TIMEOUT_MS,
|
|
506
|
-
});
|
|
507
|
-
if (result.status === 0) return { ok: true };
|
|
508
|
-
const reason = gitMessage(result);
|
|
509
|
-
return {
|
|
510
|
-
ok: false,
|
|
511
|
-
reason: reason.length > 0 ? reason : `git exited with status ${String(result.status)}`,
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* When this checkout last heard from upstream, as far as it recorded: the
|
|
517
|
-
* newer of the last fetch and the last update to the default branch's
|
|
518
|
-
* remote-tracking ref (a clone writes the second and not the first).
|
|
519
|
-
* Undefined when neither record exists.
|
|
520
|
-
*
|
|
521
|
-
* @param directory - The checkout to inspect.
|
|
522
|
-
* @param branch - The upstream default branch, when it is known.
|
|
523
|
-
* @param options - The git runner to use.
|
|
524
|
-
*/
|
|
525
|
-
export function lastFetchedAt(
|
|
526
|
-
directory: string,
|
|
527
|
-
branch: string | undefined,
|
|
528
|
-
options: GitOptions = {}
|
|
529
|
-
): Date | undefined {
|
|
530
|
-
const runner = options.runner ?? runGit;
|
|
531
|
-
const records = ["FETCH_HEAD"];
|
|
532
|
-
if (branch !== undefined) records.push(`logs/refs/remotes/${UPSTREAM_REMOTE}/${branch}`);
|
|
533
|
-
|
|
534
|
-
let newest: Date | undefined;
|
|
535
|
-
for (const record of records) {
|
|
536
|
-
const located = runner(["rev-parse", "--git-path", record], { cwd: directory });
|
|
537
|
-
if (located.status !== 0 || located.stdout.length === 0) continue;
|
|
538
|
-
const file = path.resolve(directory, located.stdout);
|
|
539
|
-
try {
|
|
540
|
-
const modified = fs.statSync(file).mtime;
|
|
541
|
-
if (newest === undefined || modified > newest) newest = modified;
|
|
542
|
-
} catch {
|
|
543
|
-
// No such record; the other one may still exist.
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
return newest;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** How a checkout's current state compares with upstream's default branch. */
|
|
550
|
-
export type UpstreamComparison = {
|
|
551
|
-
/** The branch checked out, or undefined when HEAD is detached. */
|
|
552
|
-
branch: string | undefined;
|
|
553
|
-
/** The commit HEAD points at, abbreviated. */
|
|
554
|
-
commit: string | undefined;
|
|
555
|
-
/** The upstream default branch compared against. */
|
|
556
|
-
defaultBranch: string;
|
|
557
|
-
/**
|
|
558
|
-
* True when every commit on HEAD is already on the upstream default branch;
|
|
559
|
-
* undefined when git could not tell (the remote-tracking ref is missing).
|
|
560
|
-
*/
|
|
561
|
-
merged: boolean | undefined;
|
|
562
|
-
/** Commits on the upstream default branch that HEAD lacks; undefined when git could not tell. */
|
|
563
|
-
behind: number | undefined;
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Compares a checkout's HEAD with the upstream default branch, from the
|
|
568
|
-
* remote-tracking refs as they stand. It runs no fetch of its own.
|
|
569
|
-
*
|
|
570
|
-
* @param directory - The checkout to inspect.
|
|
571
|
-
* @param branch - The upstream default branch.
|
|
572
|
-
* @param options - The git runner to use.
|
|
573
|
-
*/
|
|
574
|
-
export function compareWithUpstream(
|
|
575
|
-
directory: string,
|
|
576
|
-
branch: string,
|
|
577
|
-
options: GitOptions = {}
|
|
578
|
-
): UpstreamComparison {
|
|
579
|
-
const runner = options.runner ?? runGit;
|
|
580
|
-
const upstream = `${UPSTREAM_REMOTE}/${branch}`;
|
|
581
|
-
|
|
582
|
-
const ancestor = runner(["merge-base", "--is-ancestor", "HEAD", upstream], {
|
|
583
|
-
cwd: directory,
|
|
584
|
-
});
|
|
585
|
-
const merged = ancestor.status === 0 ? true : ancestor.status === 1 ? false : undefined;
|
|
586
|
-
|
|
587
|
-
const count = runner(["rev-list", "--count", `HEAD..${upstream}`], { cwd: directory });
|
|
588
|
-
const parsed = Number.parseInt(count.stdout, 10);
|
|
589
|
-
const behind = count.status === 0 && Number.isFinite(parsed) ? parsed : undefined;
|
|
590
|
-
|
|
591
|
-
return {
|
|
592
|
-
branch: currentBranch(directory, options),
|
|
593
|
-
commit: headCommit(directory, options),
|
|
594
|
-
defaultBranch: branch,
|
|
595
|
-
merged,
|
|
596
|
-
behind,
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Asks git whether a name is a valid branch name, and passes its refusal
|
|
602
|
-
* through when it is not. Every name the user types goes through this before
|
|
603
|
-
* it reaches any other git command, which is also what stops a name that starts
|
|
604
|
-
* with a dash from being read as an option.
|
|
605
|
-
*
|
|
606
|
-
* @param directory - The checkout the name is for.
|
|
607
|
-
* @param name - The branch name as the user typed it.
|
|
608
|
-
* @param options - The git runner to use.
|
|
609
|
-
*/
|
|
610
|
-
export function assertBranchName(directory: string, name: string, options: GitOptions = {}): void {
|
|
611
|
-
runGitOrThrow(["check-ref-format", "--branch", name], {
|
|
612
|
-
cwd: directory,
|
|
613
|
-
runner: options.runner,
|
|
614
|
-
what: `Checking the branch name '${name}'`,
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* True when the checkout has a local branch of that name.
|
|
620
|
-
*
|
|
621
|
-
* @param directory - The checkout to inspect.
|
|
622
|
-
* @param name - The branch name.
|
|
623
|
-
* @param options - The git runner to use.
|
|
624
|
-
*/
|
|
625
|
-
export function localBranchExists(
|
|
626
|
-
directory: string,
|
|
627
|
-
name: string,
|
|
628
|
-
options: GitOptions = {}
|
|
629
|
-
): boolean {
|
|
630
|
-
const runner = options.runner ?? runGit;
|
|
631
|
-
const result = runner(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], {
|
|
632
|
-
cwd: directory,
|
|
633
|
-
});
|
|
634
|
-
return result.status === 0;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Fetches one named branch from upstream into its remote-tracking ref.
|
|
639
|
-
*
|
|
640
|
-
* A clone sous makes is single-branch, so its fetch configuration covers only
|
|
641
|
-
* the default branch, and neither a plain fetch nor `git switch` would ever see
|
|
642
|
-
* another one. The branch is therefore fetched by an explicit refspec, and then
|
|
643
|
-
* added to the remote's fetch list (`git remote set-branches --add`), so later
|
|
644
|
-
* fetches keep it current and `git switch` can find it. A checkout whose fetch
|
|
645
|
-
* configuration already covers the branch is left as it is.
|
|
646
|
-
*
|
|
647
|
-
* @param directory - The checkout to fetch into.
|
|
648
|
-
* @param name - The branch to fetch.
|
|
649
|
-
* @param options - The git runner to use.
|
|
650
|
-
*/
|
|
651
|
-
export function fetchBranch(directory: string, name: string, options: GitOptions = {}): void {
|
|
652
|
-
runGitOrThrow(
|
|
653
|
-
[
|
|
654
|
-
"fetch",
|
|
655
|
-
"--quiet",
|
|
656
|
-
UPSTREAM_REMOTE,
|
|
657
|
-
`+refs/heads/${name}:refs/remotes/${UPSTREAM_REMOTE}/${name}`,
|
|
658
|
-
],
|
|
659
|
-
{
|
|
660
|
-
cwd: directory,
|
|
661
|
-
runner: options.runner,
|
|
662
|
-
what: `Fetching the branch '${name}' from ${UPSTREAM_REMOTE}`,
|
|
663
|
-
}
|
|
664
|
-
);
|
|
665
|
-
|
|
666
|
-
if (fetchConfigCovers(directory, name, options)) return;
|
|
667
|
-
|
|
668
|
-
runGitOrThrow(["remote", "set-branches", "--add", UPSTREAM_REMOTE, name], {
|
|
669
|
-
cwd: directory,
|
|
670
|
-
runner: options.runner,
|
|
671
|
-
what: `Adding the branch '${name}' to the branches ${UPSTREAM_REMOTE} is fetched for`,
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* True when the remote's configured fetch refspecs already bring the branch
|
|
677
|
-
* in: a wildcard over every branch, or the branch by name.
|
|
678
|
-
*/
|
|
679
|
-
function fetchConfigCovers(directory: string, name: string, options: GitOptions): boolean {
|
|
680
|
-
const runner = options.runner ?? runGit;
|
|
681
|
-
const result = runner(["config", "--get-all", `remote.${UPSTREAM_REMOTE}.fetch`], {
|
|
682
|
-
cwd: directory,
|
|
683
|
-
});
|
|
684
|
-
if (result.status !== 0) return false;
|
|
685
|
-
return result.stdout.split("\n").some((line) => {
|
|
686
|
-
const source = line.trim().replace(/^\+/, "").split(":")[0];
|
|
687
|
-
return source === "refs/heads/*" || source === `refs/heads/${name}`;
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Switches the checkout to an existing branch with `git switch`, which also
|
|
693
|
-
* creates a local branch tracking an upstream one of the same name.
|
|
694
|
-
*
|
|
695
|
-
* @param directory - The checkout to switch.
|
|
696
|
-
* @param name - The branch to switch to.
|
|
697
|
-
* @param options - The git runner to use.
|
|
698
|
-
*/
|
|
699
|
-
export function switchBranch(directory: string, name: string, options: GitOptions = {}): void {
|
|
700
|
-
runGitOrThrow(["switch", name], {
|
|
701
|
-
cwd: directory,
|
|
702
|
-
runner: options.runner,
|
|
703
|
-
what: `Switching to the branch '${name}'`,
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Creates a branch at a start point and switches to it, with `git switch
|
|
709
|
-
* --create`, which refuses a branch that already exists. The new branch tracks
|
|
710
|
-
* nothing, so pushing it never lands on the branch it started from.
|
|
711
|
-
*
|
|
712
|
-
* @param directory - The checkout to work in.
|
|
713
|
-
* @param name - The branch to create.
|
|
714
|
-
* @param startPoint - Where it starts, such as `origin/main`.
|
|
715
|
-
* @param options - The git runner to use.
|
|
716
|
-
*/
|
|
717
|
-
export function createBranch(
|
|
718
|
-
directory: string,
|
|
719
|
-
name: string,
|
|
720
|
-
startPoint: string,
|
|
721
|
-
options: GitOptions = {}
|
|
722
|
-
): void {
|
|
723
|
-
runGitOrThrow(["switch", "--create", name, "--no-track", startPoint], {
|
|
724
|
-
cwd: directory,
|
|
725
|
-
runner: options.runner,
|
|
726
|
-
what: `Creating the branch '${name}' from ${startPoint}`,
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/** The local work that making a branch match upstream would throw away. */
|
|
731
|
-
export type DiscardableWork = {
|
|
732
|
-
/** Changes to tracked files that are not committed, in `git status --short` form. */
|
|
733
|
-
uncommitted: string[];
|
|
734
|
-
/** Commits on the local branch that its upstream counterpart lacks, one line each. */
|
|
735
|
-
localCommits: string[];
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Lists what `resetBranchToUpstream` would discard: uncommitted changes to
|
|
740
|
-
* tracked files (untracked files are left alone by it, so they are not listed),
|
|
741
|
-
* and commits on the local branch that the fetched upstream branch lacks. Run
|
|
742
|
-
* it after fetching the branch.
|
|
743
|
-
*
|
|
744
|
-
* @param directory - The checkout to inspect.
|
|
745
|
-
* @param name - The branch that would be made to match upstream.
|
|
746
|
-
* @param options - The git runner to use.
|
|
747
|
-
*/
|
|
748
|
-
export function discardableWork(
|
|
749
|
-
directory: string,
|
|
750
|
-
name: string,
|
|
751
|
-
options: GitOptions = {}
|
|
752
|
-
): DiscardableWork {
|
|
753
|
-
const status = runGitOrThrow(["status", "--porcelain", "--untracked-files=no"], {
|
|
754
|
-
cwd: directory,
|
|
755
|
-
runner: options.runner,
|
|
756
|
-
what: "Listing the uncommitted changes",
|
|
757
|
-
});
|
|
758
|
-
const uncommitted = status.stdout
|
|
759
|
-
.split("\n")
|
|
760
|
-
.map((line) => line.trimEnd())
|
|
761
|
-
.filter((line) => line.length > 0);
|
|
762
|
-
|
|
763
|
-
let localCommits: string[] = [];
|
|
764
|
-
if (localBranchExists(directory, name, options)) {
|
|
765
|
-
const log = runGitOrThrow(
|
|
766
|
-
["log", "--oneline", "--no-decorate", `refs/remotes/${UPSTREAM_REMOTE}/${name}..refs/heads/${name}`],
|
|
767
|
-
{
|
|
768
|
-
cwd: directory,
|
|
769
|
-
runner: options.runner,
|
|
770
|
-
what: `Listing the commits on '${name}' that ${UPSTREAM_REMOTE} does not have`,
|
|
771
|
-
}
|
|
772
|
-
);
|
|
773
|
-
localCommits = log.stdout
|
|
774
|
-
.split("\n")
|
|
775
|
-
.map((line) => line.trim())
|
|
776
|
-
.filter((line) => line.length > 0);
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
return { uncommitted, localCommits };
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Switches to a branch and makes it match its fetched upstream counterpart
|
|
784
|
-
* exactly, creating the local branch when there is none. Uncommitted changes to
|
|
785
|
-
* tracked files and local commits upstream lacks are discarded, which is why a
|
|
786
|
-
* caller lists them with `discardableWork` and asks first. Every other branch
|
|
787
|
-
* is left as it is.
|
|
788
|
-
*
|
|
789
|
-
* @param directory - The checkout to work in.
|
|
790
|
-
* @param name - The branch to update.
|
|
791
|
-
* @param options - The git runner to use.
|
|
792
|
-
*/
|
|
793
|
-
export function resetBranchToUpstream(
|
|
794
|
-
directory: string,
|
|
795
|
-
name: string,
|
|
796
|
-
options: GitOptions = {}
|
|
797
|
-
): void {
|
|
798
|
-
runGitOrThrow(
|
|
799
|
-
["switch", "--discard-changes", "--force-create", name, `${UPSTREAM_REMOTE}/${name}`],
|
|
800
|
-
{
|
|
801
|
-
cwd: directory,
|
|
802
|
-
runner: options.runner,
|
|
803
|
-
what: `Making the branch '${name}' match ${UPSTREAM_REMOTE}/${name}`,
|
|
804
|
-
}
|
|
805
|
-
);
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* The name `--generate-branch` gives a new branch: `sous/edit-<YYYYMMDD>-<HHMM>`,
|
|
810
|
-
* in local time.
|
|
811
|
-
*
|
|
812
|
-
* @param now - The moment to name it after. Defaults to now.
|
|
813
|
-
*/
|
|
814
|
-
export function generatedBranchName(now: Date = new Date()): string {
|
|
815
|
-
const pad = (value: number) => String(value).padStart(2, "0");
|
|
816
|
-
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
817
|
-
const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
818
|
-
return `sous/edit-${date}-${time}`;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// --- Small filesystem helpers -------------------------------------------------------------------
|
|
822
|
-
|
|
823
|
-
/** Replaces anything outside a safe path segment, so a URL can never escape the store. */
|
|
824
|
-
function sanitizeSegment(segment: string): string {
|
|
825
|
-
const cleaned = segment.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^\.+/, "");
|
|
826
|
-
return cleaned.length > 0 ? cleaned : "repo";
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/** True when the path exists and is a directory. */
|
|
830
|
-
function directoryExists(candidate: string): boolean {
|
|
831
|
-
try {
|
|
832
|
-
return fs.statSync(candidate).isDirectory();
|
|
833
|
-
} catch {
|
|
834
|
-
return false;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/** True when the path is a directory holding nothing at all. */
|
|
839
|
-
function isEmptyDirectory(candidate: string): boolean {
|
|
840
|
-
try {
|
|
841
|
-
return fs.readdirSync(candidate).length === 0;
|
|
842
|
-
} catch {
|
|
843
|
-
return false;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** Compares two paths after resolving them, so a trailing slash never matters. */
|
|
848
|
-
function samePath(a: string, b: string): boolean {
|
|
849
|
-
return path.resolve(a) === path.resolve(b);
|
|
850
|
-
}
|