@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.
Files changed (244) hide show
  1. package/README.md +14 -256
  2. package/dist/bin.js +11 -0
  3. package/dist/sous.js +1093 -0
  4. package/docs/markdown/README.md +5 -4
  5. package/docs/markdown/_sidebar.md +21 -1
  6. package/docs/markdown/commands.md +294 -431
  7. package/docs/markdown/config-discovery.md +25 -14
  8. package/docs/markdown/config-inspection.md +53 -31
  9. package/docs/markdown/config-layers.md +168 -66
  10. package/docs/markdown/config-variables.md +44 -11
  11. package/docs/markdown/configuration.md +104 -17
  12. package/docs/markdown/design-principles.md +4 -1
  13. package/docs/markdown/repositories-authoring.md +30 -0
  14. package/docs/markdown/repositories-consuming.md +72 -14
  15. package/docs/markdown/repositories-file-formats.md +31 -13
  16. package/docs/markdown/repositories-providers.md +48 -57
  17. package/docs/markdown/repositories-quickstart.md +3 -2
  18. package/docs/markdown/repositories-troubleshooting.md +27 -1
  19. package/docs/markdown/repositories-variables.md +6 -1
  20. package/docs/markdown/repositories.md +18 -8
  21. package/package.json +48 -107
  22. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
  23. package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
  24. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
  25. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
  26. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
  27. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  28. package/sous.config.schema.json +351 -132
  29. package/bin/run.js +0 -51
  30. package/src/base-command.ts +0 -457
  31. package/src/commands/build.ts +0 -181
  32. package/src/commands/clear.ts +0 -79
  33. package/src/commands/compile.ts +0 -155
  34. package/src/commands/config/get.ts +0 -110
  35. package/src/commands/config/show.ts +0 -32
  36. package/src/commands/config/validate.ts +0 -53
  37. package/src/commands/help.ts +0 -46
  38. package/src/commands/init.ts +0 -289
  39. package/src/commands/launch.ts +0 -139
  40. package/src/commands/lock/rebuild.ts +0 -239
  41. package/src/commands/lock/show.ts +0 -115
  42. package/src/commands/namespace/list.ts +0 -138
  43. package/src/commands/namespace/show.ts +0 -130
  44. package/src/commands/prune.ts +0 -43
  45. package/src/commands/recipe/list.ts +0 -112
  46. package/src/commands/recipe/show.ts +0 -439
  47. package/src/commands/repo/add.ts +0 -145
  48. package/src/commands/repo/contribute.ts +0 -604
  49. package/src/commands/repo/gc.ts +0 -173
  50. package/src/commands/repo/init.ts +0 -136
  51. package/src/commands/repo/link.ts +0 -899
  52. package/src/commands/repo/list.ts +0 -236
  53. package/src/commands/repo/release.ts +0 -685
  54. package/src/commands/repo/remove.ts +0 -194
  55. package/src/commands/repo/search.ts +0 -243
  56. package/src/commands/repo/submit.ts +0 -341
  57. package/src/commands/repo/unlink.ts +0 -461
  58. package/src/commands/subscription/add.ts +0 -286
  59. package/src/commands/subscription/list.ts +0 -208
  60. package/src/commands/subscription/remove.ts +0 -182
  61. package/src/commands/subscription/update.ts +0 -216
  62. package/src/commands/update.ts +0 -178
  63. package/src/commands/vars/ask.ts +0 -417
  64. package/src/commands/vars/index.ts +0 -79
  65. package/src/commands/vars/list.ts +0 -67
  66. package/src/commands/vars/show.ts +0 -77
  67. package/src/config-command.ts +0 -30
  68. package/src/lib/build-preparation.ts +0 -128
  69. package/src/lib/build-service.ts +0 -580
  70. package/src/lib/config-discovery.ts +0 -378
  71. package/src/lib/config-inspect.ts +0 -145
  72. package/src/lib/config-kernel.mjs +0 -379
  73. package/src/lib/config-schema.ts +0 -401
  74. package/src/lib/env-file.ts +0 -328
  75. package/src/lib/env-local.ts +0 -212
  76. package/src/lib/errors.ts +0 -32
  77. package/src/lib/glob-pattern.ts +0 -24
  78. package/src/lib/include-resolver.ts +0 -523
  79. package/src/lib/interactive.ts +0 -165
  80. package/src/lib/markdown-compiler.ts +0 -947
  81. package/src/lib/package-info.ts +0 -25
  82. package/src/lib/pid-service.ts +0 -99
  83. package/src/lib/project-install.d.mts +0 -44
  84. package/src/lib/project-install.mjs +0 -234
  85. package/src/lib/project-scaffold/index.ts +0 -332
  86. package/src/lib/project-scaffold/templates.ts +0 -240
  87. package/src/lib/repos/catalog-display.ts +0 -215
  88. package/src/lib/repos/catalog-inputs.ts +0 -288
  89. package/src/lib/repos/catalog.ts +0 -842
  90. package/src/lib/repos/contribute.ts +0 -452
  91. package/src/lib/repos/core-recipe.ts +0 -105
  92. package/src/lib/repos/declarations.ts +0 -187
  93. package/src/lib/repos/defaults.ts +0 -175
  94. package/src/lib/repos/formats/common.ts +0 -440
  95. package/src/lib/repos/formats/index-file.ts +0 -253
  96. package/src/lib/repos/formats/links-map.ts +0 -98
  97. package/src/lib/repos/formats/lockfile.ts +0 -169
  98. package/src/lib/repos/formats/patterns.ts +0 -57
  99. package/src/lib/repos/formats/recipe-manifest.ts +0 -205
  100. package/src/lib/repos/formats/repo-manifest.ts +0 -97
  101. package/src/lib/repos/formats/store-entry.ts +0 -84
  102. package/src/lib/repos/formats/variable-definition.ts +0 -268
  103. package/src/lib/repos/freshness.ts +0 -264
  104. package/src/lib/repos/git-clone.ts +0 -850
  105. package/src/lib/repos/identity.ts +0 -89
  106. package/src/lib/repos/index.ts +0 -57
  107. package/src/lib/repos/links.ts +0 -356
  108. package/src/lib/repos/load-manifest.ts +0 -237
  109. package/src/lib/repos/lock-service.ts +0 -454
  110. package/src/lib/repos/locked-namespace-resolver.ts +0 -232
  111. package/src/lib/repos/locked-recipe-lookup.ts +0 -123
  112. package/src/lib/repos/locked-recipes.ts +0 -254
  113. package/src/lib/repos/managed-layer.ts +0 -422
  114. package/src/lib/repos/memories-hash-name.ts +0 -33
  115. package/src/lib/repos/namespace-resolver.ts +0 -527
  116. package/src/lib/repos/providers/base.ts +0 -268
  117. package/src/lib/repos/providers/git.ts +0 -245
  118. package/src/lib/repos/providers/github.ts +0 -589
  119. package/src/lib/repos/providers/gitlab.ts +0 -332
  120. package/src/lib/repos/providers/http.ts +0 -128
  121. package/src/lib/repos/providers/index-cache.ts +0 -421
  122. package/src/lib/repos/providers/index.ts +0 -106
  123. package/src/lib/repos/providers/local.ts +0 -410
  124. package/src/lib/repos/providers/provider.ts +0 -615
  125. package/src/lib/repos/recipe-config-layers.ts +0 -316
  126. package/src/lib/repos/recipe-key-matcher.ts +0 -62
  127. package/src/lib/repos/recipe-memories.ts +0 -178
  128. package/src/lib/repos/recipe-targets.ts +0 -236
  129. package/src/lib/repos/ref-search.ts +0 -46
  130. package/src/lib/repos/release/bump.ts +0 -161
  131. package/src/lib/repos/release/changelog.ts +0 -461
  132. package/src/lib/repos/release/git-state.ts +0 -460
  133. package/src/lib/repos/release/index-builder.ts +0 -1108
  134. package/src/lib/repos/release/index.ts +0 -19
  135. package/src/lib/repos/release/plan.ts +0 -521
  136. package/src/lib/repos/release/settle.ts +0 -154
  137. package/src/lib/repos/release/submissions.ts +0 -214
  138. package/src/lib/repos/release/submit-checkout.ts +0 -290
  139. package/src/lib/repos/release/submit-questions.ts +0 -153
  140. package/src/lib/repos/release/submit-service.ts +0 -898
  141. package/src/lib/repos/release/tags.ts +0 -239
  142. package/src/lib/repos/release/validate.ts +0 -463
  143. package/src/lib/repos/resolver.ts +0 -1028
  144. package/src/lib/repos/scaffold/index.ts +0 -245
  145. package/src/lib/repos/scaffold/templates.ts +0 -517
  146. package/src/lib/repos/seed.ts +0 -473
  147. package/src/lib/repos/store/contract.ts +0 -64
  148. package/src/lib/repos/store/hash.ts +0 -191
  149. package/src/lib/repos/store/recipe-store.ts +0 -599
  150. package/src/lib/repos/store/settings.ts +0 -58
  151. package/src/lib/repos/subscription-service.ts +0 -3859
  152. package/src/lib/repos/trust.ts +0 -447
  153. package/src/lib/repos/update-plan.ts +0 -235
  154. package/src/lib/self-update/index.ts +0 -14
  155. package/src/lib/self-update/installs.ts +0 -413
  156. package/src/lib/self-update/managers.ts +0 -126
  157. package/src/lib/self-update/plan.ts +0 -315
  158. package/src/lib/self-update/registry.ts +0 -99
  159. package/src/lib/self-update/run.ts +0 -506
  160. package/src/lib/self-update/versions.ts +0 -243
  161. package/src/lib/settings.ts +0 -1107
  162. package/src/lib/sous-home.ts +0 -104
  163. package/src/lib/state.ts +0 -167
  164. package/src/lib/vars/answers.ts +0 -192
  165. package/src/lib/vars/ask.ts +0 -1152
  166. package/src/lib/vars/definition-source.ts +0 -261
  167. package/src/lib/vars/display.ts +0 -231
  168. package/src/lib/vars/index.ts +0 -20
  169. package/src/lib/vars/ladder.ts +0 -282
  170. package/src/lib/vars/mappings.ts +0 -260
  171. package/src/lib/vars/names.ts +0 -94
  172. package/src/lib/vars/preanswers.ts +0 -395
  173. package/src/lib/vars/question-plan.ts +0 -241
  174. package/src/lib/vars/report.ts +0 -228
  175. package/src/lib/vars/safe-regex.ts +0 -235
  176. package/src/lib/vars/secrets.ts +0 -131
  177. package/src/lib/vars/validate.ts +0 -312
  178. package/src/lib/version-report.ts +0 -90
  179. package/src/lib/watch-loop.ts +0 -148
  180. package/src/lib/watch-service.ts +0 -116
  181. package/src/services/ref-resolver/container.ts +0 -107
  182. package/src/services/ref-resolver/dependency.ts +0 -60
  183. package/src/services/ref-resolver/format.ts +0 -197
  184. package/src/services/ref-resolver/glob.ts +0 -115
  185. package/src/services/ref-resolver/hash-names.ts +0 -155
  186. package/src/services/ref-resolver/index.ts +0 -55
  187. package/src/services/ref-resolver/injectable.ts +0 -36
  188. package/src/services/ref-resolver/location.ts +0 -62
  189. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
  190. package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
  191. package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
  192. package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
  193. package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
  194. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
  195. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
  196. package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
  197. package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
  198. package/src/services/ref-resolver/named-refs.ts +0 -144
  199. package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
  200. package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
  201. package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
  202. package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
  203. package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
  204. package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
  205. package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
  206. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
  207. package/src/services/ref-resolver/parts.ts +0 -115
  208. package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
  209. package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
  210. package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
  211. package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
  212. package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
  213. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
  214. package/src/services/ref-resolver/ref-helpers.ts +0 -62
  215. package/src/services/ref-resolver/ref-picker-service.ts +0 -204
  216. package/src/services/ref-resolver/ref-report.ts +0 -84
  217. package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
  218. package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
  219. package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
  220. package/src/services/ref-resolver/source.ts +0 -48
  221. package/src/services/ref-resolver/tokens.ts +0 -25
  222. package/src/services/ref-resolver/types.ts +0 -155
  223. package/src/templating/filters/bullet-list.ts +0 -9
  224. package/src/templating/filters/index.ts +0 -8
  225. package/src/templating/init-liquid-engine.ts +0 -139
  226. package/src/templating/lib/glob-files.ts +0 -74
  227. package/src/templating/lib/import-export.ts +0 -32
  228. package/src/templating/lib/secret-scope.ts +0 -41
  229. package/src/templating/lib/tag-args.ts +0 -19
  230. package/src/templating/tags/exportScalarVarsJs.ts +0 -46
  231. package/src/templating/tags/getFiles.ts +0 -89
  232. package/src/templating/tags/index.ts +0 -14
  233. package/src/templating/tags/listFiles.ts +0 -54
  234. package/src/templating/tags/showVars.ts +0 -28
  235. package/src/utils/choice-prompt.ts +0 -143
  236. package/src/utils/command-errors.ts +0 -186
  237. package/src/utils/command-help.ts +0 -45
  238. package/src/utils/confirm-prompt.ts +0 -110
  239. package/src/utils/flags.ts +0 -177
  240. package/src/utils/formatting.ts +0 -849
  241. package/src/utils/prompts.ts +0 -53
  242. package/src/utils/sous-directory.ts +0 -245
  243. package/src/utils/table.ts +0 -603
  244. 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
- }