@sous-io/sous 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Raising, or setting, the version in a recipe manifest.
3
+ *
4
+ * A recipe manifest is HAND-WRITTEN, and the scaffold sous writes is mostly
5
+ * comments, so a version bump must give the file back to its author looking the
6
+ * way they left it. Both supported formats are edited in place rather than
7
+ * re-serialized from a parsed object: YAML through the `yaml` package's
8
+ * document model, which keeps comments and layout, and JSON through
9
+ * `jsonc-parser`, which edits the exact byte range of the value and leaves
10
+ * everything else, comments included, untouched.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import semver from "semver";
16
+ import YAML from "yaml";
17
+ import { applyEdits, modify } from "jsonc-parser";
18
+ import { ConfigError } from "../../errors.js";
19
+ import { parseJsoncText } from "../load-manifest.js";
20
+
21
+ /** How far a version is being raised. */
22
+ export const BUMP_LEVELS = ["patch", "minor", "major", "prerelease"] as const;
23
+
24
+ /** One of the ways `sous repo release --bump` can raise a version. */
25
+ export type BumpLevel = (typeof BUMP_LEVELS)[number];
26
+
27
+ /** What one bump did. */
28
+ export type BumpResult = {
29
+ /** The manifest that was rewritten. */
30
+ manifestPath: string;
31
+ /** The version it declared before. */
32
+ from: string;
33
+ /** The version it declares now. */
34
+ to: string;
35
+ };
36
+
37
+ /**
38
+ * The version one level up from the current one, using npm's own semantics.
39
+ *
40
+ * @param current - The version the manifest declares now.
41
+ * @param level - How far to raise it.
42
+ */
43
+ export function nextVersion(current: string, level: BumpLevel): string {
44
+ const next = semver.inc(current, level);
45
+ if (next === null) {
46
+ throw new ConfigError(
47
+ `Cannot raise the version '${current}' by a ${level} step.\n` +
48
+ ` A recipe version is an exact semantic version, such as '1.4.0' or '2.0.0-beta.1'.`
49
+ );
50
+ }
51
+ return next;
52
+ }
53
+
54
+ /**
55
+ * Rewrites a recipe manifest's `version` field in place, preserving the rest of
56
+ * the file as written.
57
+ *
58
+ * @param manifestPath - Absolute path to the recipe manifest.
59
+ * @param level - How far to raise the version.
60
+ */
61
+ export function bumpRecipeVersion(manifestPath: string, level: BumpLevel): BumpResult {
62
+ return rewriteVersion(manifestPath, (current) => nextVersion(current, level));
63
+ }
64
+
65
+ /**
66
+ * Writes an exact version into a recipe manifest, preserving the rest of the
67
+ * file as written.
68
+ *
69
+ * This is what the release pipeline uses to hold the packaged core recipe at
70
+ * the sous package's own version (see `scripts/sync-core-version.mts`), where
71
+ * the new version is dictated rather than stepped. A manifest that already
72
+ * declares this version is left untouched, byte for byte, so running the sync
73
+ * twice cannot reformat a hand-written file.
74
+ *
75
+ * @param manifestPath - Absolute path to the recipe manifest.
76
+ * @param version - The exact semantic version the manifest should declare.
77
+ */
78
+ export function setRecipeVersion(manifestPath: string, version: string): BumpResult {
79
+ if (semver.valid(version) === null) {
80
+ throw new ConfigError(
81
+ `Cannot set the recipe version to '${version}'.\n` +
82
+ ` A recipe version is an exact semantic version, such as '1.4.0' or '2.0.0-beta.1'.`
83
+ );
84
+ }
85
+ return rewriteVersion(manifestPath, () => version);
86
+ }
87
+
88
+ /**
89
+ * The one writer both callers share: read the version the manifest declares,
90
+ * work out what it becomes, and rewrite that value alone.
91
+ *
92
+ * @param manifestPath - Absolute path to the recipe manifest.
93
+ * @param nextFrom - Given the declared version, the version to write.
94
+ */
95
+ function rewriteVersion(
96
+ manifestPath: string,
97
+ nextFrom: (current: string) => string
98
+ ): BumpResult {
99
+ const text = fs.readFileSync(manifestPath, "utf8");
100
+ const extension = path.extname(manifestPath).toLowerCase();
101
+
102
+ if (extension === ".json" || extension === ".jsonc") {
103
+ return rewriteJson(manifestPath, text, nextFrom);
104
+ }
105
+ return rewriteYaml(manifestPath, text, nextFrom);
106
+ }
107
+
108
+ /** Rewrites the version in a YAML manifest, keeping its comments and layout. */
109
+ function rewriteYaml(
110
+ manifestPath: string,
111
+ text: string,
112
+ nextFrom: (current: string) => string
113
+ ): BumpResult {
114
+ const document = YAML.parseDocument(text);
115
+ const node = document.get("version", true);
116
+
117
+ if (!YAML.isScalar(node) || typeof node.value !== "string") {
118
+ throw missingVersion(manifestPath);
119
+ }
120
+
121
+ const from = node.value;
122
+ const to = nextFrom(from);
123
+ if (to === from) return { manifestPath, from, to };
124
+
125
+ node.value = to;
126
+ fs.writeFileSync(manifestPath, document.toString(), "utf8");
127
+
128
+ return { manifestPath, from, to };
129
+ }
130
+
131
+ /** Rewrites the version in a JSON or JSONC manifest, editing only that value's bytes. */
132
+ function rewriteJson(
133
+ manifestPath: string,
134
+ text: string,
135
+ nextFrom: (current: string) => string
136
+ ): BumpResult {
137
+ // The manifest dialect allows comments and trailing commas, so it is read
138
+ // through the loader's own permissive parser rather than JSON.parse.
139
+ const parsed = parseJsoncText(text, manifestPath);
140
+ const from =
141
+ typeof parsed === "object" && parsed !== null
142
+ ? (parsed as Record<string, unknown>).version
143
+ : undefined;
144
+ if (typeof from !== "string") throw missingVersion(manifestPath);
145
+
146
+ const to = nextFrom(from);
147
+ if (to === from) return { manifestPath, from, to };
148
+
149
+ const edits = modify(text, ["version"], to, {});
150
+ fs.writeFileSync(manifestPath, applyEdits(text, edits), "utf8");
151
+
152
+ return { manifestPath, from, to };
153
+ }
154
+
155
+ /** The error for a manifest with no usable `version` field. */
156
+ function missingVersion(manifestPath: string): ConfigError {
157
+ return new ConfigError(
158
+ `The recipe manifest at ${manifestPath} has no 'version' field to write.\n` +
159
+ ` Every recipe declares an exact semantic version; add one, then try again.`
160
+ );
161
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * What git says about the working tree a release or a submission is standing in.
3
+ *
4
+ * Both `sous repo release` and `sous repo submit` need the same handful of
5
+ * facts before they are allowed to do anything: is anything uncommitted, which
6
+ * branch is checked out, which branch is the default one, and where does
7
+ * `origin` point. A release makes exactly one commit of its own (its version
8
+ * bumps and its index, through `commitPaths`) and refuses while anything else
9
+ * is uncommitted; everything else here is read to refuse politely rather than
10
+ * to fix anything.
11
+ *
12
+ * Every function takes the injectable command runner, so tests never spawn git
13
+ * unless they mean to.
14
+ */
15
+
16
+ import { runGit, type RunOptions } from "../providers/git.js";
17
+
18
+ /** One path git reports as changed, with the two-letter status it reported. */
19
+ export type ChangedPath = {
20
+ /** The status code from `git status --porcelain`, such as `M `, `??` or `A `. */
21
+ status: string;
22
+ /** The path, relative to the repository root. */
23
+ path: string;
24
+ };
25
+
26
+ /**
27
+ * Everything `git status --porcelain` reports: staged changes, unstaged changes
28
+ * and untracked files alike. An empty list means the working tree is clean.
29
+ *
30
+ * @param rootDir - The repository's root directory.
31
+ * @param options - The command runner to use.
32
+ */
33
+ export async function uncommittedChanges(
34
+ rootDir: string,
35
+ options: RunOptions = {}
36
+ ): Promise<ChangedPath[]> {
37
+ const output = await runGit(["status", "--porcelain"], {
38
+ cwd: rootDir,
39
+ run: options.run,
40
+ });
41
+ if (output.length === 0) return [];
42
+
43
+ const changed: ChangedPath[] = [];
44
+ for (const line of output.split("\n")) {
45
+ // The status field is one or two characters, and the command runner trims
46
+ // its output, so a leading space (an unstaged edit) is already gone by the
47
+ // time the line arrives here. Split on the whitespace instead of counting
48
+ // columns. A rename is reported as 'old -> new'; the new name is the one
49
+ // that still exists, so that is the one reported.
50
+ const match = /^(\S{1,2})\s+(.*)$/.exec(line.trim());
51
+ if (match === null) continue;
52
+ const target = match[2]!;
53
+ const arrow = target.indexOf(" -> ");
54
+ changed.push({
55
+ status: match[1]!,
56
+ path: arrow === -1 ? target : target.slice(arrow + 4),
57
+ });
58
+ }
59
+ return changed;
60
+ }
61
+
62
+ /**
63
+ * The name of the checked-out branch, or undefined when HEAD is detached (which
64
+ * is what a CI checkout of a tag looks like).
65
+ *
66
+ * @param rootDir - The repository's root directory.
67
+ * @param options - The command runner to use.
68
+ */
69
+ export async function currentBranch(
70
+ rootDir: string,
71
+ options: RunOptions = {}
72
+ ): Promise<string | undefined> {
73
+ try {
74
+ const name = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], {
75
+ cwd: rootDir,
76
+ run: options.run,
77
+ });
78
+ return name === "HEAD" ? undefined : name;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The repository's default branch, taken from what `origin/HEAD` points at.
86
+ * Returns undefined when the remote never told this clone, which is normal for
87
+ * a repository cloned with `--depth 1` or created locally.
88
+ *
89
+ * @param rootDir - The repository's root directory.
90
+ * @param options - The command runner to use.
91
+ */
92
+ export async function defaultBranch(
93
+ rootDir: string,
94
+ options: RunOptions = {}
95
+ ): Promise<string | undefined> {
96
+ try {
97
+ const ref = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
98
+ cwd: rootDir,
99
+ run: options.run,
100
+ });
101
+ const slash = ref.indexOf("/");
102
+ return slash === -1 ? ref : ref.slice(slash + 1);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The URL of a remote, or undefined when the repository has no such remote.
110
+ *
111
+ * @param rootDir - The repository's root directory.
112
+ * @param remote - The remote's name, normally `origin`.
113
+ * @param options - The command runner to use.
114
+ */
115
+ export async function remoteUrl(
116
+ rootDir: string,
117
+ remote: string,
118
+ options: RunOptions = {}
119
+ ): Promise<string | undefined> {
120
+ try {
121
+ const url = await runGit(["remote", "get-url", remote], {
122
+ cwd: rootDir,
123
+ run: options.run,
124
+ });
125
+ return url.length > 0 ? url : undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * True when a path is tracked by git and identical to what HEAD holds. It is how
133
+ * a caller confirms that a file it is about to act on is the one the repository
134
+ * actually committed.
135
+ *
136
+ * @param rootDir - The repository's root directory.
137
+ * @param relativePath - The path to check, relative to the repository root.
138
+ * @param options - The command runner to use.
139
+ */
140
+ export async function isCommittedAndUnchanged(
141
+ rootDir: string,
142
+ relativePath: string,
143
+ options: RunOptions = {}
144
+ ): Promise<boolean> {
145
+ const tracked = await runGit(["ls-files", "--", relativePath], {
146
+ cwd: rootDir,
147
+ run: options.run,
148
+ });
149
+ if (tracked.length === 0) return false;
150
+
151
+ const changed = await runGit(["status", "--porcelain", "--", relativePath], {
152
+ cwd: rootDir,
153
+ run: options.run,
154
+ });
155
+ return changed.length === 0;
156
+ }
157
+
158
+ /**
159
+ * True when git can work out who is committing, which is what it needs before
160
+ * it will make a commit or an annotated tag.
161
+ *
162
+ * `git var GIT_AUTHOR_IDENT` answers the exact question git asks itself: it
163
+ * honours the `user.name` and `user.email` settings, the `GIT_AUTHOR_*` and
164
+ * `GIT_COMMITTER_*` environment variables, and the strict rules that reject a
165
+ * guessed identity. It fails with the same "empty ident name" or "unable to
166
+ * auto-detect email address" that a commit would have failed with, which is
167
+ * why the check is made ahead of time rather than left to the commit.
168
+ *
169
+ * @param rootDir - The repository's root directory.
170
+ * @param options - The command runner to use.
171
+ */
172
+ export async function hasCommitIdentity(
173
+ rootDir: string,
174
+ options: RunOptions = {}
175
+ ): Promise<boolean> {
176
+ for (const name of ["GIT_AUTHOR_IDENT", "GIT_COMMITTER_IDENT"]) {
177
+ try {
178
+ const ident = await runGit(["var", name], { cwd: rootDir, run: options.run });
179
+ if (ident.length === 0) return false;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Stages exactly the given paths and commits them.
189
+ *
190
+ * This is the one place sous commits on an author's behalf, and it is
191
+ * deliberately narrow: a release writes version bumps and an index, and those
192
+ * are the only paths it stages. Anything else in the working tree is left
193
+ * exactly as it was.
194
+ *
195
+ * @param rootDir - The repository's root directory.
196
+ * @param paths - The paths to stage, relative to the repository root.
197
+ * @param message - The commit message.
198
+ * @param options - The command runner to use.
199
+ */
200
+ export async function commitPaths(
201
+ rootDir: string,
202
+ paths: ReadonlyArray<string>,
203
+ message: string,
204
+ options: RunOptions = {}
205
+ ): Promise<void> {
206
+ if (paths.length === 0) return;
207
+ await runGit(["add", "--", ...paths], { cwd: rootDir, run: options.run });
208
+ await runGit(["commit", "--message", message, "--", ...paths], {
209
+ cwd: rootDir,
210
+ run: options.run,
211
+ });
212
+ }
213
+
214
+ /**
215
+ * True when any of the given paths differs from what HEAD holds, so a release
216
+ * knows whether it has anything to commit.
217
+ *
218
+ * @param rootDir - The repository's root directory.
219
+ * @param paths - The paths to check, relative to the repository root.
220
+ * @param options - The command runner to use.
221
+ */
222
+ export async function anythingToCommit(
223
+ rootDir: string,
224
+ paths: ReadonlyArray<string>,
225
+ options: RunOptions = {}
226
+ ): Promise<boolean> {
227
+ if (paths.length === 0) return false;
228
+ const changed = await runGit(["status", "--porcelain", "--", ...paths], {
229
+ cwd: rootDir,
230
+ run: options.run,
231
+ });
232
+ return changed.length > 0;
233
+ }
234
+
235
+ /**
236
+ * Creates a branch at HEAD and checks it out.
237
+ *
238
+ * @param rootDir - The repository's root directory.
239
+ * @param branch - The branch to create.
240
+ * @param options - The command runner to use.
241
+ */
242
+ export async function createBranch(
243
+ rootDir: string,
244
+ branch: string,
245
+ options: RunOptions = {}
246
+ ): Promise<void> {
247
+ await runGit(["checkout", "-b", branch], { cwd: rootDir, run: options.run });
248
+ }
249
+
250
+ /**
251
+ * Pushes one branch to a remote, setting it as the branch's upstream.
252
+ *
253
+ * @param rootDir - The repository's root directory.
254
+ * @param remote - The remote to push to.
255
+ * @param branch - The branch to push.
256
+ * @param options - The command runner to use.
257
+ */
258
+ export async function pushBranch(
259
+ rootDir: string,
260
+ remote: string,
261
+ branch: string,
262
+ options: RunOptions = {}
263
+ ): Promise<void> {
264
+ await runGit(["push", "--set-upstream", remote, branch], {
265
+ cwd: rootDir,
266
+ run: options.run,
267
+ });
268
+ }
269
+
270
+ /**
271
+ * The subject line of the most recent commit, or undefined when there is none.
272
+ * It is the default title for a proposed change, which is what a contributor
273
+ * would have typed anyway.
274
+ *
275
+ * @param rootDir - The repository's root directory.
276
+ * @param options - The command runner to use.
277
+ */
278
+ export async function lastCommitSubject(
279
+ rootDir: string,
280
+ options: RunOptions = {}
281
+ ): Promise<string | undefined> {
282
+ try {
283
+ const subject = await runGit(["log", "-1", "--format=%s"], {
284
+ cwd: rootDir,
285
+ run: options.run,
286
+ });
287
+ return subject.length > 0 ? subject : undefined;
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * The branch name sous proposes for a submission, stamped with the minute it
295
+ * was made so two submissions from one checkout never collide.
296
+ *
297
+ * @param now - The moment the branch is being created.
298
+ */
299
+ export function submitBranchName(now: Date): string {
300
+ const pad = (value: number) => String(value).padStart(2, "0");
301
+ const stamp =
302
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
303
+ `-${pad(now.getHours())}${pad(now.getMinutes())}`;
304
+ return `sous/submit-${stamp}`;
305
+ }