@sous-io/sous 0.2.14 → 0.2.15

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.
@@ -177,10 +177,30 @@ Points a repository at a working copy on this machine instead of a published ver
177
177
  clones it into `.sous/repos` and links the clone; a name or URL with a `PATH` links the checkout at that path; a
178
178
  path alone links that checkout where it is, adding the repository first if needed. Takes `--dry-run`.
179
179
 
180
- - `--global`: link for every project on this machine, sharing one checkout.
181
- - `-y, --yes`: answer the trust question a not-yet-added repository raises (also `--trust`).
182
-
183
- Example: `sous repo link sous-recipes ~/Projects/sous-recipes`
180
+ A checkout that was already on disk is fetched (a fetch changes none of its files or branches) and compared with
181
+ upstream: its branch, whether that branch is merged into the default branch, and how many commits it is behind.
182
+ When upstream cannot be reached within a few seconds, a warning gives git's reason and says since when the
183
+ checkout may have diverged, and the link is still recorded. Nothing else changes the checkout unless a flag
184
+ asks for it; git carries out each step, and a step git refuses stops the command with git's own message.
185
+
186
+ - `--global`: link for every project on this machine, sharing one checkout. Changing that checkout's branch
187
+ says it affects every project that links it.
188
+ - `--branch <name>`: switch to an existing branch, fetching it from upstream first when it is not local.
189
+ - `--create-branch <name>`: create a new branch and switch to it; git refuses a name that already exists.
190
+ - `--generate-branch`: the same, with the generated name `sous/edit-<YYYYMMDD>-<HHMM>`, which is printed.
191
+ - `--from <branch>`: the base of the new branch, fetched first. Defaults to the repository's default branch,
192
+ not whatever is checked out, and needs `--create-branch` or `--generate-branch`.
193
+ - `--latest`: make the branch being worked from (the `--branch` target, the `--from` base, or else the default
194
+ branch) match upstream's, leaving every other branch alone. What that would discard (uncommitted changes and
195
+ local commits upstream lacks) is listed first, with one question; it fails when the fetch fails.
196
+ - `-y, --yes`: answer the trust question a not-yet-added repository raises and the question `--latest` asks
197
+ (also `-f`, `--force`, `--trust`).
198
+
199
+ `--branch`, `--create-branch` and `--generate-branch` exclude each other; each flag works on a checkout linked
200
+ by path, too.
201
+
202
+ Example: `sous repo link sous-recipes ~/Projects/sous-recipes`, or `sous repo link sous-recipes
203
+ --generate-branch --latest --yes`
184
204
 
185
205
  ### `sous repo unlink REPO`
186
206
  Stops reading a repository from a working copy and goes back to published versions; `REPO` is the short name as
@@ -289,6 +289,59 @@ Run 'sous repo unlink my-recipes' to go back to published versions.
289
289
  `sous repo unlink` removes the map entry and nothing else: the checkout stays where it is, and its path is
290
290
  printed so you can delete it. Unlinking a name linked in the other scope says which scope holds it.
291
291
 
292
+ ### How a checkout compares with upstream
293
+
294
+ A link says which checkout to read, not why, so `sous repo link` never changes a checkout on its own. When the
295
+ checkout was already on disk (a clone reused from an earlier link, or one you named by path), it runs a short
296
+ `git fetch`, which updates only the remote-tracking refs, and reports what it found. The part of the output that
297
+ reports it looks like this:
298
+
299
+ ```term
300
+ $ sous repo link my-recipes
301
+ Branch : lc/my-change
302
+ Compared with : origin/main
303
+ Merged into origin/main: yes
304
+ Behind origin/main : 12 commits
305
+ ```
306
+
307
+ A branch that is merged and behind has usually been released already, and the checkout is building from an older
308
+ state than upstream's. When the fetch fails or takes longer than a few seconds, the link is still recorded, and
309
+ a warning gives git's reason and says since when the checkout may have diverged (the last time it was fetched).
310
+ The fetch is skipped for a checkout sous has just cloned, and a checkout with no `origin` remote says it has no
311
+ upstream to compare with.
312
+
313
+ ### Choosing the branch
314
+
315
+ Every change to the checkout is a flag, and git carries each one out. When git refuses a step (a conflict with
316
+ uncommitted changes, a branch that already exists, one that does not), the command stops and shows git's message
317
+ under a line naming the step. Each flag works on a checkout linked by path, too.
318
+
319
+ ```bash
320
+ sous repo link my-recipes --branch lc/their-change # read from an existing branch
321
+ sous repo link my-recipes --create-branch my-change # start a new branch
322
+ sous repo link my-recipes --generate-branch # start one named sous/edit-<YYYYMMDD>-<HHMM>
323
+ sous repo link my-recipes --generate-branch --from next # start it from another branch
324
+ sous repo link my-recipes --latest # bring the default branch up to upstream's
325
+ ```
326
+
327
+ - `--branch <name>` switches with `git switch`, which also checks out a branch that exists only upstream. A clone
328
+ sous makes holds only the default branch, so a branch that is not local is fetched first and added to the
329
+ branches `origin` is fetched for.
330
+ - `--create-branch <name>` and `--generate-branch` create a new branch and switch to it; the generated name is
331
+ printed. The new branch starts from upstream's copy of `--from`, which defaults to the repository's default
332
+ branch rather than whatever is checked out, so a new branch never silently stacks on an old, already-merged
333
+ one. `--from` is an error without one of the two.
334
+ - `--branch`, `--create-branch` and `--generate-branch` exclude each other.
335
+ - `--latest` means upstream's latest version of what you are working from: the `--branch` target, the `--from`
336
+ base, or otherwise the default branch. It fetches that branch, switches to it and makes it match upstream's,
337
+ and leaves every other branch alone. Making a branch match upstream discards local work without git warning
338
+ about it, so this is the one place sous checks for itself: uncommitted changes to tracked files and local
339
+ commits upstream lacks are listed first, with one question. `--yes` answers it, and a run with no terminal
340
+ fails naming that flag. When the fetch fails, `--latest` fails, with git's reason.
341
+
342
+ A `--global` link's checkout is shared by every project on the machine that links the repository globally, so
343
+ changing its branch says it affects all of them.
344
+
292
345
  ## Contribute to someone else's repository
293
346
 
294
347
  `sous repo submit` proposes your committed changes to a repository's maintainers; it never publishes and never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.14
14
+ version: 0.2.15
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -12,13 +12,30 @@ import { enabledRepos } from "../../lib/repos/defaults.js";
12
12
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
13
13
  import type { LinkOrigin } from "../../lib/repos/formats/links-map.js";
14
14
  import {
15
+ assertBranchName,
15
16
  cloneRepo,
17
+ compareWithUpstream,
18
+ createBranch,
19
+ currentBranch,
20
+ defaultBranch,
21
+ discardableWork,
22
+ fetchBranch,
23
+ generatedBranchName,
16
24
  isGitCheckout,
25
+ lastFetchedAt,
26
+ localBranchExists,
17
27
  looksLikeRepoUrl,
18
28
  remoteUrlOf,
19
29
  repoSlugFromUrl,
30
+ resetBranchToUpstream,
20
31
  sameRemote,
32
+ switchBranch,
33
+ tryFetchUpstream,
34
+ UPSTREAM_REMOTE,
35
+ type DiscardableWork,
21
36
  } from "../../lib/repos/git-clone.js";
37
+ import { isInteractive, nonInteractiveError } from "../../lib/interactive.js";
38
+ import { askYesNo } from "../../utils/prompts.js";
22
39
  import {
23
40
  assertLocalRepoDirectory,
24
41
  expandHomePath,
@@ -36,12 +53,16 @@ import {
36
53
  writeProjectLinks,
37
54
  } from "../../lib/repos/links.js";
38
55
  import {
56
+ BULLET,
39
57
  blankLine,
40
58
  dryRunNotice,
41
59
  footer,
42
60
  heading,
43
61
  log,
62
+ note,
63
+ paragraph,
44
64
  showCommandVars,
65
+ showVariable,
45
66
  showVariables,
46
67
  warning,
47
68
  } from "../../utils/formatting.js";
@@ -68,6 +89,15 @@ import {
68
89
  * adding one. Naming a repository this project has not added therefore runs the
69
90
  * same trust ceremony `sous repo add` runs, rather than skipping it; there is no
70
91
  * way to read from a repository this project does not trust.
92
+ *
93
+ * A link says nothing about WHY a checkout is being read (authoring, running a
94
+ * teammate's branch, a local fork, debugging), so the command never changes a
95
+ * checkout on its own. A checkout that was already on disk is fetched and
96
+ * compared with upstream, which changes none of its files or branches; every
97
+ * change to it is an explicit flag (`--branch`, `--create-branch`,
98
+ * `--generate-branch`, `--latest`), carried out by git, whose refusals are
99
+ * passed through. `--latest` is the one place sous checks for itself, because
100
+ * making a branch match upstream discards local work without git warning.
71
101
  */
72
102
  export default class RepoLink extends BaseCommand {
73
103
  static description = [
@@ -79,6 +109,8 @@ export default class RepoLink extends BaseCommand {
79
109
  "that clone.",
80
110
  "'sous repo link <name-or-url> <path>' links the checkout at that path to that " +
81
111
  "repository, and clones nothing.",
112
+ "A checkout that was already on disk is fetched and compared with upstream; " +
113
+ "only the branch flags and --latest change it.",
82
114
  ].join("\n");
83
115
 
84
116
  /**
@@ -93,6 +125,9 @@ export default class RepoLink extends BaseCommand {
93
125
  "<%= config.bin %> repo link sous-recipes ~/Projects/sous-recipes",
94
126
  "<%= config.bin %> repo link https://github.com/sous-io/sous-recipes",
95
127
  "<%= config.bin %> repo link sous-recipes --global",
128
+ "<%= config.bin %> repo link sous-recipes --latest",
129
+ "<%= config.bin %> repo link sous-recipes --generate-branch",
130
+ "<%= config.bin %> repo link sous-recipes --branch my-change --latest",
96
131
  ];
97
132
 
98
133
  static args = {
@@ -124,6 +159,33 @@ export default class RepoLink extends BaseCommand {
124
159
  description: "Print what would change without cloning or writing anything",
125
160
  default: false,
126
161
  }),
162
+ branch: Flags.string({
163
+ description: "Switch the checkout to this existing branch, fetching it from upstream first",
164
+ helpValue: "<name>",
165
+ exclusive: ["create-branch", "generate-branch"],
166
+ }),
167
+ "create-branch": Flags.string({
168
+ description: "Create this new branch in the checkout and switch to it",
169
+ helpValue: "<name>",
170
+ exclusive: ["branch", "generate-branch"],
171
+ }),
172
+ "generate-branch": Flags.boolean({
173
+ description: "Create a new branch named sous/edit-<date>-<time> in the checkout and switch to it",
174
+ exclusive: ["branch", "create-branch"],
175
+ }),
176
+ from: Flags.string({
177
+ description:
178
+ "Start the new branch from this branch instead of the repository's default branch",
179
+ helpValue: "<branch>",
180
+ // oclif's `dependsOn` wants every listed flag, and these two exclude each
181
+ // other; `some` is its spelling of "at least one of".
182
+ relationships: [{ type: "some", flags: ["create-branch", "generate-branch"] }],
183
+ }),
184
+ latest: Flags.boolean({
185
+ description:
186
+ "Make the branch being worked from match upstream's, after listing any local work that would be discarded",
187
+ default: false,
188
+ }),
127
189
  };
128
190
 
129
191
  async run(): Promise<void> {
@@ -175,9 +237,12 @@ export default class RepoLink extends BaseCommand {
175
237
  ? this.planLinkToPath(existingCheckout)
176
238
  : this.planClone(name, url, isGlobal, dryRun);
177
239
 
240
+ const request = branchRequestFrom(flags);
241
+
178
242
  if (dryRun) {
179
243
  blankLine();
180
244
  dryRunNotice(`would link '${name}' to ${plan.directory}`);
245
+ for (const line of describeBranchRequest(request)) dryRunNotice(line);
181
246
  dryRunNotice(
182
247
  `would record it in ${isGlobal ? "the machine-wide" : "this project's"} links map`
183
248
  );
@@ -185,6 +250,19 @@ export default class RepoLink extends BaseCommand {
185
250
  return;
186
251
  }
187
252
 
253
+ // Branch work happens before the link is recorded, so a step git refuses
254
+ // leaves no link pointing at a checkout in a state nobody asked for.
255
+ if (hasBranchWork(request)) {
256
+ if (isGlobal) {
257
+ warning(
258
+ `This checkout is SHARED by every project on this machine that links '${name}' ` +
259
+ `with --global.\n` +
260
+ `Changing its branch changes what all of them build from.`
261
+ );
262
+ }
263
+ plan.notes.push(...(await this.applyBranchRequest(plan.directory, request, flags.yes)));
264
+ }
265
+
188
266
  const map = isGlobal ? readGlobalLinks() : readProjectLinks(sousDir);
189
267
  const previous = map.links[name];
190
268
  map.links[name] = {
@@ -204,9 +282,11 @@ export default class RepoLink extends BaseCommand {
204
282
  for (const line of plan.notes) log(` ${line}`);
205
283
  if (plan.notes.length > 0) blankLine();
206
284
 
285
+ const branch = isGitCheckout(plan.directory) ? currentBranch(plan.directory) : undefined;
207
286
  showVariables({
208
287
  Repository: name,
209
288
  Checkout: plan.directory,
289
+ ...(branch === undefined ? {} : { Branch: branch }),
210
290
  "Recorded in": linksPath,
211
291
  });
212
292
 
@@ -215,6 +295,10 @@ export default class RepoLink extends BaseCommand {
215
295
  log(` This replaces an earlier link to ${previous.path}, which is untouched.`);
216
296
  }
217
297
 
298
+ // A checkout sous has just cloned is as current as upstream by definition;
299
+ // any other one may be days or months old, so say how it compares.
300
+ if (plan.kind !== "cloned") this.reportUpstream(plan.directory);
301
+
218
302
  warning(
219
303
  `The repository '${name}' is now LINKED.\n` +
220
304
  `Its recipes are read from the checkout above, so versions, the lockfile\n` +
@@ -227,6 +311,227 @@ export default class RepoLink extends BaseCommand {
227
311
  footer();
228
312
  }
229
313
 
314
+ /**
315
+ * Carries out the branch flags on the checkout, in order: `--latest` first
316
+ * (which switches to the branch being worked from and makes it match
317
+ * upstream's), then `--branch` when `--latest` did not already switch to it,
318
+ * then the new branch. Git decides whether each step may happen; the one
319
+ * thing checked here is what `--latest` would discard, because making a
320
+ * branch match upstream discards work without git warning about it.
321
+ *
322
+ * @param directory - The checkout.
323
+ * @param request - What the flags asked for.
324
+ * @param yes - The confirmation flag, which answers the discard question.
325
+ * @returns Lines describing what was done, for the notes block.
326
+ */
327
+ private async applyBranchRequest(
328
+ directory: string,
329
+ request: BranchRequest,
330
+ yes: boolean
331
+ ): Promise<string[]> {
332
+ if (!isGitCheckout(directory)) {
333
+ throw new ConfigError(
334
+ `${directory} is not a git checkout, so it has no branches to switch or create.\n` +
335
+ ` Link it without the branch flags, or turn it into a git repository first.`
336
+ );
337
+ }
338
+
339
+ for (const name of [request.switchTo, request.create, request.from]) {
340
+ if (name !== undefined) assertBranchName(directory, name);
341
+ }
342
+
343
+ const hasUpstream = remoteUrlOf(directory) !== undefined;
344
+ const needsDefault =
345
+ (request.create !== undefined && request.from === undefined) ||
346
+ (request.latest && request.switchTo === undefined && request.from === undefined);
347
+ const fallback = needsDefault ? this.requireDefaultBranch(directory) : undefined;
348
+ const notes: string[] = [];
349
+
350
+ if (request.latest) {
351
+ const target = (request.switchTo ?? request.from ?? fallback)!;
352
+ if (!hasUpstream) {
353
+ throw new ConfigError(
354
+ `--latest makes '${target}' match upstream's, and this checkout has no ` +
355
+ `'${UPSTREAM_REMOTE}' remote to be upstream.\n` +
356
+ ` Add one with 'git remote add ${UPSTREAM_REMOTE} <url>' in ${directory}, or ` +
357
+ `link without --latest.`
358
+ );
359
+ }
360
+ fetchBranch(directory, target);
361
+ const work = discardableWork(directory, target);
362
+ await this.confirmDiscard(target, work, yes);
363
+ resetBranchToUpstream(directory, target);
364
+ notes.push(`Made the branch '${target}' match ${UPSTREAM_REMOTE}/${target}.`);
365
+ } else if (request.switchTo !== undefined) {
366
+ // A branch that exists locally is switched to as it is; one that does not
367
+ // is fetched first, since a single-branch clone cannot see it otherwise.
368
+ if (hasUpstream && !localBranchExists(directory, request.switchTo)) {
369
+ fetchBranch(directory, request.switchTo);
370
+ }
371
+ switchBranch(directory, request.switchTo);
372
+ notes.push(`Switched to the branch '${request.switchTo}'.`);
373
+ }
374
+
375
+ if (request.create !== undefined) {
376
+ const base = (request.from ?? fallback)!;
377
+ let startPoint = base;
378
+ if (hasUpstream) {
379
+ // With --latest the base was fetched a moment ago.
380
+ if (!request.latest) fetchBranch(directory, base);
381
+ startPoint = `${UPSTREAM_REMOTE}/${base}`;
382
+ }
383
+ createBranch(directory, request.create, startPoint);
384
+ notes.push(
385
+ request.generated
386
+ ? `Created the branch '${request.create}' (a generated name) from ${startPoint}, ` +
387
+ `and switched to it.`
388
+ : `Created the branch '${request.create}' from ${startPoint}, and switched to it.`
389
+ );
390
+ }
391
+
392
+ return notes;
393
+ }
394
+
395
+ /**
396
+ * The upstream default branch, or a ConfigError saying it could not be
397
+ * worked out and which flag names a branch instead.
398
+ *
399
+ * @param directory - The checkout.
400
+ */
401
+ private requireDefaultBranch(directory: string): string {
402
+ const found = defaultBranch(directory);
403
+ if (found !== undefined) return found;
404
+ throw new ConfigError(
405
+ `Could not work out the default branch of the repository checked out at ${directory}.\n` +
406
+ ` git records it as '${UPSTREAM_REMOTE}/HEAD' when it clones, and this checkout ` +
407
+ `has no such record, nor could '${UPSTREAM_REMOTE}' be asked for it.\n` +
408
+ ` Name the branch to work from with --from, or with --branch.`
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Lists what `--latest` would discard and asks once before going on. Nothing
414
+ * is asked when there is nothing to discard, and the confirmation flag
415
+ * answers the question ahead of time.
416
+ *
417
+ * @param branch - The branch being made to match upstream's.
418
+ * @param work - What would be discarded.
419
+ * @param yes - The confirmation flag.
420
+ */
421
+ private async confirmDiscard(
422
+ branch: string,
423
+ work: DiscardableWork,
424
+ yes: boolean
425
+ ): Promise<void> {
426
+ if (work.uncommitted.length === 0 && work.localCommits.length === 0) return;
427
+
428
+ blankLine();
429
+ paragraph(
430
+ `Making '${branch}' match ${UPSTREAM_REMOTE}/${branch} discards the local work below.`,
431
+ { indent: 2 }
432
+ );
433
+ if (work.uncommitted.length > 0) {
434
+ blankLine();
435
+ showVariable("Uncommitted changes", work.uncommitted.length);
436
+ for (const line of work.uncommitted) log(` ${BULLET} ${line}`);
437
+ }
438
+ if (work.localCommits.length > 0) {
439
+ blankLine();
440
+ showVariable(`Commits ${UPSTREAM_REMOTE} does not have`, work.localCommits.length);
441
+ for (const line of work.localCommits) log(` ${BULLET} ${line}`);
442
+ }
443
+
444
+ if (yes) return;
445
+
446
+ if (!isInteractive()) {
447
+ throw nonInteractiveError({
448
+ prompt: `whether to discard the local work on '${branch}' listed above`,
449
+ remedy:
450
+ "pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to discard it " +
451
+ "without being asked.",
452
+ });
453
+ }
454
+
455
+ const proceed = await askYesNo("Discard it?");
456
+ if (!proceed) {
457
+ throw new ConfigError(
458
+ `Nothing was changed: the local work on '${branch}' was kept.\n` +
459
+ ` The checkout is on the branch it was on before, and no link was recorded.`
460
+ );
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Says how a checkout that was already on disk compares with upstream. A
466
+ * short fetch comes first; it updates only the remote-tracking refs, never
467
+ * the user's files or branches. When upstream cannot be reached, the link
468
+ * still stands and the warning says since when the checkout may have
469
+ * diverged.
470
+ *
471
+ * @param directory - The checkout.
472
+ */
473
+ private reportUpstream(directory: string): void {
474
+ if (!isGitCheckout(directory)) return;
475
+
476
+ blankLine();
477
+ if (remoteUrlOf(directory) === undefined) {
478
+ note(
479
+ `The checkout has no '${UPSTREAM_REMOTE}' remote, so there is no upstream to ` +
480
+ `compare it with.`,
481
+ { indent: 2 }
482
+ );
483
+ return;
484
+ }
485
+
486
+ const fetched = tryFetchUpstream(directory);
487
+ const branch = defaultBranch(directory);
488
+
489
+ if (!fetched.ok) {
490
+ const since = lastFetchedAt(directory, branch);
491
+ warning(
492
+ `Could not reach upstream; the checkout may have diverged since ` +
493
+ (since === undefined
494
+ ? `it was last fetched, and git has no record of when that was.`
495
+ : `${formatWhen(since)}, when it was last fetched.`) +
496
+ `\n\nGit said:\n${fetched.reason}\n\n` +
497
+ `The link was recorded all the same, and nothing in the checkout was changed.`
498
+ );
499
+ return;
500
+ }
501
+
502
+ if (branch === undefined) {
503
+ note(
504
+ `Upstream was fetched, but its default branch could not be worked out, so there ` +
505
+ `is nothing to compare the checkout with.`,
506
+ { indent: 2 }
507
+ );
508
+ return;
509
+ }
510
+
511
+ const comparison = compareWithUpstream(directory, branch);
512
+ const upstream = `${UPSTREAM_REMOTE}/${branch}`;
513
+ const yesNo = (value: boolean | undefined) =>
514
+ value === undefined ? "unknown" : value ? "yes" : "no";
515
+
516
+ // The branch is already in the summary above; a detached HEAD has none
517
+ // there, so it is named here instead.
518
+ showVariables({
519
+ ...(comparison.branch === undefined
520
+ ? { "Checked out": `no branch; HEAD is detached at ${comparison.commit ?? "an unknown commit"}` }
521
+ : {}),
522
+ "Compared with": upstream,
523
+ [`Merged into ${upstream}`]: yesNo(comparison.merged),
524
+ [`Behind ${upstream}`]:
525
+ comparison.behind === undefined
526
+ ? "unknown"
527
+ : `${comparison.behind} ${comparison.behind === 1 ? "commit" : "commits"}`,
528
+ });
529
+ blankLine();
530
+ note(`Upstream was fetched just now; nothing in the checkout was changed.`, {
531
+ indent: 2,
532
+ });
533
+ }
534
+
230
535
  /**
231
536
  * The checkout a REPO argument names outright, as an absolute path, or
232
537
  * undefined when the argument is a short name or a URL instead.
@@ -381,6 +686,7 @@ export default class RepoLink extends BaseCommand {
381
686
  return {
382
687
  directory,
383
688
  origin: "path",
689
+ kind: "path",
384
690
  notes: [`Linked the checkout already at ${directory}.`],
385
691
  };
386
692
  }
@@ -429,6 +735,7 @@ export default class RepoLink extends BaseCommand {
429
735
  return {
430
736
  directory,
431
737
  origin: "clone",
738
+ kind: "reused",
432
739
  notes: [
433
740
  `Reused the checkout already at ${directory}; nothing was cloned.`,
434
741
  ],
@@ -439,6 +746,7 @@ export default class RepoLink extends BaseCommand {
439
746
  return {
440
747
  directory,
441
748
  origin: "clone",
749
+ kind: "cloned",
442
750
  notes: [`Would clone ${url} into ${directory}.`],
443
751
  };
444
752
  }
@@ -468,7 +776,7 @@ export default class RepoLink extends BaseCommand {
468
776
  );
469
777
  }
470
778
 
471
- return { directory, origin: "clone", notes };
779
+ return { directory, origin: "clone", kind: "cloned", notes };
472
780
  }
473
781
  }
474
782
 
@@ -495,6 +803,97 @@ type LinkPlan = {
495
803
  directory: string;
496
804
  /** Whether sous cloned it or was pointed at it. */
497
805
  origin: LinkOrigin;
806
+ /**
807
+ * What this run did to get the checkout: cloned it just now, reused a clone
808
+ * already in place, or was given the path of one.
809
+ */
810
+ kind: "cloned" | "reused" | "path";
498
811
  /** Lines describing what happened, printed before the summary. */
499
812
  notes: string[];
500
813
  };
814
+
815
+ /** What the branch flags asked for, with a generated name already chosen. */
816
+ type BranchRequest = {
817
+ /** `--branch`: the existing branch to switch to. */
818
+ switchTo?: string;
819
+ /** `--create-branch` or `--generate-branch`: the branch to create. */
820
+ create?: string;
821
+ /** True when the name to create was generated rather than typed. */
822
+ generated: boolean;
823
+ /** `--from`: the base of the new branch; the default branch when unset. */
824
+ from?: string;
825
+ /** `--latest`: make the branch being worked from match upstream's. */
826
+ latest: boolean;
827
+ };
828
+
829
+ /**
830
+ * Reads the branch flags into one request. The generated name is chosen here,
831
+ * once, so the dry run and the real run would print the same thing.
832
+ *
833
+ * @param flags - The parsed flags.
834
+ */
835
+ function branchRequestFrom(flags: {
836
+ branch?: string;
837
+ "create-branch"?: string;
838
+ "generate-branch"?: boolean;
839
+ from?: string;
840
+ latest: boolean;
841
+ }): BranchRequest {
842
+ const generated = flags["generate-branch"] === true;
843
+ const create = generated ? generatedBranchName() : flags["create-branch"];
844
+ return {
845
+ ...(flags.branch === undefined ? {} : { switchTo: flags.branch }),
846
+ ...(create === undefined ? {} : { create }),
847
+ generated,
848
+ ...(flags.from === undefined ? {} : { from: flags.from }),
849
+ latest: flags.latest,
850
+ };
851
+ }
852
+
853
+ /** True when the request asks for anything to be done to the checkout. */
854
+ function hasBranchWork(request: BranchRequest): boolean {
855
+ return request.switchTo !== undefined || request.create !== undefined || request.latest;
856
+ }
857
+
858
+ /**
859
+ * What a dry run says the branch flags would do, one line per step, in the
860
+ * order the real run takes them.
861
+ *
862
+ * @param request - The branch request.
863
+ */
864
+ function describeBranchRequest(request: BranchRequest): string[] {
865
+ const lines: string[] = [];
866
+ const workingFrom = request.switchTo ?? request.from ?? "the default branch";
867
+ const quoted = (name: string) => (name === "the default branch" ? name : `'${name}'`);
868
+
869
+ if (request.latest) {
870
+ lines.push(
871
+ `would fetch ${quoted(workingFrom)}, list any local work on it that would be ` +
872
+ `discarded, and make it match upstream's`
873
+ );
874
+ } else if (request.switchTo !== undefined) {
875
+ lines.push(`would switch the checkout to the branch '${request.switchTo}'`);
876
+ }
877
+
878
+ if (request.create !== undefined) {
879
+ lines.push(
880
+ `would create the branch '${request.create}' from upstream's ` +
881
+ `${quoted(request.from ?? "the default branch")} and switch to it`
882
+ );
883
+ }
884
+ return lines;
885
+ }
886
+
887
+ /**
888
+ * Renders a moment as `YYYY-MM-DD HH:MM` in local time, which is what a person
889
+ * reads a "since when" as.
890
+ *
891
+ * @param when - The moment to render.
892
+ */
893
+ function formatWhen(when: Date): string {
894
+ const pad = (value: number) => String(value).padStart(2, "0");
895
+ return (
896
+ `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} ` +
897
+ `${pad(when.getHours())}:${pad(when.getMinutes())}`
898
+ );
899
+ }
@@ -27,13 +27,25 @@ export type GitResult = {
27
27
  stderr: string;
28
28
  };
29
29
 
30
+ /** Where and for how long one git command may run. */
31
+ export type GitRunOptions = {
32
+ /** The directory git runs in. */
33
+ cwd?: string;
34
+ /**
35
+ * How long git may run, in milliseconds, before it is stopped. A command that
36
+ * runs out of time comes back with a null status and a sentence saying so in
37
+ * `stderr`, never as an exception. Unset means no limit.
38
+ */
39
+ timeoutMs?: number;
40
+ };
41
+
30
42
  /**
31
43
  * Runs one git command. Swappable so tests never need a real git binary.
32
44
  *
33
45
  * @param args - The arguments passed to git, without the leading "git".
34
- * @param options - Where to run it.
46
+ * @param options - Where to run it, and for how long.
35
47
  */
36
- export type GitRunner = (args: string[], options: { cwd?: string }) => GitResult;
48
+ export type GitRunner = (args: string[], options: GitRunOptions) => GitResult;
37
49
 
38
50
  /** Options shared by every function here. */
39
51
  export type GitOptions = {
@@ -54,8 +66,22 @@ export const runGit: GitRunner = (args, options = {}) => {
54
66
  // A clone must never stop to ask for a password; a prompt in a
55
67
  // non-interactive run would hang the command with no explanation.
56
68
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
69
+ timeout: options.timeoutMs,
70
+ killSignal: "SIGKILL",
57
71
  });
58
72
 
73
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") {
74
+ const seconds = Math.round((options.timeoutMs ?? 0) / 1000);
75
+ const partial = (result.stderr ?? "").trim();
76
+ return {
77
+ status: null,
78
+ stdout: (result.stdout ?? "").trim(),
79
+ stderr:
80
+ `git did not finish within ${seconds} seconds and was stopped.` +
81
+ (partial.length > 0 ? `\n${partial}` : ""),
82
+ };
83
+ }
84
+
59
85
  if (result.error !== undefined) {
60
86
  const reason = (result.error as NodeJS.ErrnoException).code === "ENOENT"
61
87
  ? "git is not installed, or is not on your PATH"
@@ -75,25 +101,42 @@ export const runGit: GitRunner = (args, options = {}) => {
75
101
  };
76
102
  };
77
103
 
78
- /** Runs git and throws a ConfigError, carrying git's own message, on failure. */
104
+ /**
105
+ * Runs git and throws a ConfigError on failure: a first line naming the step
106
+ * that failed, then the command, then git's own message, line for line. Git is
107
+ * the authority on what it will and will not do to a checkout, so its refusal
108
+ * is passed through rather than paraphrased.
109
+ */
79
110
  function runGitOrThrow(
80
111
  args: string[],
81
- options: { cwd?: string; runner?: GitRunner; what: string }
112
+ options: { cwd?: string; runner?: GitRunner; what: string; timeoutMs?: number }
82
113
  ): GitResult {
83
114
  const runner = options.runner ?? runGit;
84
- const result = runner(args, { cwd: options.cwd });
115
+ const result = runner(args, { cwd: options.cwd, timeoutMs: options.timeoutMs });
85
116
  if (result.status !== 0) {
86
- const detail = result.stderr.length > 0 ? result.stderr : result.stdout;
117
+ const detail = gitMessage(result);
118
+ const said =
119
+ detail.length > 0
120
+ ? ` git said:\n${detail
121
+ .split("\n")
122
+ .map((line) => ` ${line}`)
123
+ .join("\n")}\n`
124
+ : "";
87
125
  throw new ConfigError(
88
126
  `${options.what} failed.\n` +
89
127
  ` Command: git ${args.join(" ")}\n` +
90
- (detail.length > 0 ? ` git said: ${detail}\n` : "") +
128
+ said +
91
129
  ` Fix the problem git reported, then run the command again.`
92
130
  );
93
131
  }
94
132
  return result;
95
133
  }
96
134
 
135
+ /** What git said about a result: its error output, or its standard output when that is all. */
136
+ function gitMessage(result: GitResult): string {
137
+ return result.stderr.length > 0 ? result.stderr : result.stdout;
138
+ }
139
+
97
140
  /**
98
141
  * True when the directory is inside a git working tree whose root is that same
99
142
  * directory. A subdirectory of a checkout is deliberately not a checkout here:
@@ -280,6 +323,424 @@ export function looksLikeRepoUrl(value: string): boolean {
280
323
  return value.startsWith("/") || value.startsWith("./") || value.startsWith("../");
281
324
  }
282
325
 
326
+ // --- Branches and upstream ----------------------------------------------------------------------
327
+ //
328
+ // Everything below works on the `origin` remote, which is the one a clone
329
+ // creates and the one `remoteUrlOf` reads. Each function either reports a fact
330
+ // or runs exactly one git operation; when git refuses an operation, its own
331
+ // message is passed through under a line naming the step, and nothing here
332
+ // second-guesses it. The one exception is `discardableWork`, which exists
333
+ // because making a branch match upstream discards work without git warning
334
+ // about it.
335
+
336
+ /** The remote every function here reads from and fetches. */
337
+ export const UPSTREAM_REMOTE = "origin";
338
+
339
+ /**
340
+ * How long the fetch behind the divergence report may take, in milliseconds.
341
+ * It is tight on purpose: the report is a courtesy, and an unreachable host
342
+ * must fall back to a warning rather than hold the link up.
343
+ */
344
+ export const UPSTREAM_CHECK_TIMEOUT_MS = 10_000;
345
+
346
+ /** What a fetch that is allowed to fail produced. */
347
+ export type FetchOutcome =
348
+ | { ok: true }
349
+ | {
350
+ ok: false;
351
+ /** Git's own explanation, or the sentence saying it ran out of time. */
352
+ reason: string;
353
+ };
354
+
355
+ /**
356
+ * The branch a checkout has checked out, or undefined when HEAD is detached
357
+ * (a tag or a bare commit is checked out instead of a branch).
358
+ *
359
+ * @param directory - The checkout to inspect.
360
+ * @param options - The git runner to use.
361
+ */
362
+ export function currentBranch(directory: string, options: GitOptions = {}): string | undefined {
363
+ const runner = options.runner ?? runGit;
364
+ const result = runner(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: directory });
365
+ if (result.status !== 0 || result.stdout.length === 0) return undefined;
366
+ return result.stdout;
367
+ }
368
+
369
+ /**
370
+ * The abbreviated commit HEAD points at, for describing a detached checkout.
371
+ *
372
+ * @param directory - The checkout to inspect.
373
+ * @param options - The git runner to use.
374
+ */
375
+ export function headCommit(directory: string, options: GitOptions = {}): string | undefined {
376
+ const runner = options.runner ?? runGit;
377
+ const result = runner(["rev-parse", "--short", "HEAD"], { cwd: directory });
378
+ if (result.status !== 0 || result.stdout.length === 0) return undefined;
379
+ return result.stdout;
380
+ }
381
+
382
+ /**
383
+ * The upstream repository's default branch, or undefined when it cannot be
384
+ * worked out. A clone records it as `origin/HEAD`, which is read first and
385
+ * needs no network; a checkout that lacks that record (one made with `git
386
+ * init` and a remote added later, say) is asked about over the network, under
387
+ * the same tight timeout as the upstream check.
388
+ *
389
+ * @param directory - The checkout to inspect.
390
+ * @param options - The git runner to use.
391
+ */
392
+ export function defaultBranch(directory: string, options: GitOptions = {}): string | undefined {
393
+ const runner = options.runner ?? runGit;
394
+ const prefix = `${UPSTREAM_REMOTE}/`;
395
+
396
+ const local = runner(
397
+ ["symbolic-ref", "--quiet", "--short", `refs/remotes/${UPSTREAM_REMOTE}/HEAD`],
398
+ { cwd: directory }
399
+ );
400
+ if (local.status === 0 && local.stdout.startsWith(prefix)) {
401
+ return local.stdout.slice(prefix.length);
402
+ }
403
+
404
+ const remote = runner(["ls-remote", "--symref", UPSTREAM_REMOTE, "HEAD"], {
405
+ cwd: directory,
406
+ timeoutMs: UPSTREAM_CHECK_TIMEOUT_MS,
407
+ });
408
+ if (remote.status !== 0) return undefined;
409
+ const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(remote.stdout);
410
+ return match?.[1];
411
+ }
412
+
413
+ /**
414
+ * Fetches from upstream, allowing the fetch to fail. A fetch updates only the
415
+ * remote-tracking refs, never the user's files or branches, which is why the
416
+ * divergence report may run one unasked.
417
+ *
418
+ * @param directory - The checkout to fetch into.
419
+ * @param options - The git runner to use, and how long the fetch may take.
420
+ */
421
+ export function tryFetchUpstream(
422
+ directory: string,
423
+ options: GitOptions & { timeoutMs?: number } = {}
424
+ ): FetchOutcome {
425
+ const runner = options.runner ?? runGit;
426
+ const result = runner(["fetch", "--quiet", UPSTREAM_REMOTE], {
427
+ cwd: directory,
428
+ timeoutMs: options.timeoutMs ?? UPSTREAM_CHECK_TIMEOUT_MS,
429
+ });
430
+ if (result.status === 0) return { ok: true };
431
+ const reason = gitMessage(result);
432
+ return {
433
+ ok: false,
434
+ reason: reason.length > 0 ? reason : `git exited with status ${String(result.status)}`,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * When this checkout last heard from upstream, as far as it recorded: the
440
+ * newer of the last fetch and the last update to the default branch's
441
+ * remote-tracking ref (a clone writes the second and not the first).
442
+ * Undefined when neither record exists.
443
+ *
444
+ * @param directory - The checkout to inspect.
445
+ * @param branch - The upstream default branch, when it is known.
446
+ * @param options - The git runner to use.
447
+ */
448
+ export function lastFetchedAt(
449
+ directory: string,
450
+ branch: string | undefined,
451
+ options: GitOptions = {}
452
+ ): Date | undefined {
453
+ const runner = options.runner ?? runGit;
454
+ const records = ["FETCH_HEAD"];
455
+ if (branch !== undefined) records.push(`logs/refs/remotes/${UPSTREAM_REMOTE}/${branch}`);
456
+
457
+ let newest: Date | undefined;
458
+ for (const record of records) {
459
+ const located = runner(["rev-parse", "--git-path", record], { cwd: directory });
460
+ if (located.status !== 0 || located.stdout.length === 0) continue;
461
+ const file = path.resolve(directory, located.stdout);
462
+ try {
463
+ const modified = fs.statSync(file).mtime;
464
+ if (newest === undefined || modified > newest) newest = modified;
465
+ } catch {
466
+ // No such record; the other one may still exist.
467
+ }
468
+ }
469
+ return newest;
470
+ }
471
+
472
+ /** How a checkout's current state compares with upstream's default branch. */
473
+ export type UpstreamComparison = {
474
+ /** The branch checked out, or undefined when HEAD is detached. */
475
+ branch: string | undefined;
476
+ /** The commit HEAD points at, abbreviated. */
477
+ commit: string | undefined;
478
+ /** The upstream default branch compared against. */
479
+ defaultBranch: string;
480
+ /**
481
+ * True when every commit on HEAD is already on the upstream default branch;
482
+ * undefined when git could not tell (the remote-tracking ref is missing).
483
+ */
484
+ merged: boolean | undefined;
485
+ /** Commits on the upstream default branch that HEAD lacks; undefined when git could not tell. */
486
+ behind: number | undefined;
487
+ };
488
+
489
+ /**
490
+ * Compares a checkout's HEAD with the upstream default branch, from the
491
+ * remote-tracking refs as they stand. It runs no fetch of its own.
492
+ *
493
+ * @param directory - The checkout to inspect.
494
+ * @param branch - The upstream default branch.
495
+ * @param options - The git runner to use.
496
+ */
497
+ export function compareWithUpstream(
498
+ directory: string,
499
+ branch: string,
500
+ options: GitOptions = {}
501
+ ): UpstreamComparison {
502
+ const runner = options.runner ?? runGit;
503
+ const upstream = `${UPSTREAM_REMOTE}/${branch}`;
504
+
505
+ const ancestor = runner(["merge-base", "--is-ancestor", "HEAD", upstream], {
506
+ cwd: directory,
507
+ });
508
+ const merged = ancestor.status === 0 ? true : ancestor.status === 1 ? false : undefined;
509
+
510
+ const count = runner(["rev-list", "--count", `HEAD..${upstream}`], { cwd: directory });
511
+ const parsed = Number.parseInt(count.stdout, 10);
512
+ const behind = count.status === 0 && Number.isFinite(parsed) ? parsed : undefined;
513
+
514
+ return {
515
+ branch: currentBranch(directory, options),
516
+ commit: headCommit(directory, options),
517
+ defaultBranch: branch,
518
+ merged,
519
+ behind,
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Asks git whether a name is a valid branch name, and passes its refusal
525
+ * through when it is not. Every name the user types goes through this before
526
+ * it reaches any other git command, which is also what stops a name that starts
527
+ * with a dash from being read as an option.
528
+ *
529
+ * @param directory - The checkout the name is for.
530
+ * @param name - The branch name as the user typed it.
531
+ * @param options - The git runner to use.
532
+ */
533
+ export function assertBranchName(directory: string, name: string, options: GitOptions = {}): void {
534
+ runGitOrThrow(["check-ref-format", "--branch", name], {
535
+ cwd: directory,
536
+ runner: options.runner,
537
+ what: `Checking the branch name '${name}'`,
538
+ });
539
+ }
540
+
541
+ /**
542
+ * True when the checkout has a local branch of that name.
543
+ *
544
+ * @param directory - The checkout to inspect.
545
+ * @param name - The branch name.
546
+ * @param options - The git runner to use.
547
+ */
548
+ export function localBranchExists(
549
+ directory: string,
550
+ name: string,
551
+ options: GitOptions = {}
552
+ ): boolean {
553
+ const runner = options.runner ?? runGit;
554
+ const result = runner(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], {
555
+ cwd: directory,
556
+ });
557
+ return result.status === 0;
558
+ }
559
+
560
+ /**
561
+ * Fetches one named branch from upstream into its remote-tracking ref.
562
+ *
563
+ * A clone sous makes is single-branch, so its fetch configuration covers only
564
+ * the default branch, and neither a plain fetch nor `git switch` would ever see
565
+ * another one. The branch is therefore fetched by an explicit refspec, and then
566
+ * added to the remote's fetch list (`git remote set-branches --add`), so later
567
+ * fetches keep it current and `git switch` can find it. A checkout whose fetch
568
+ * configuration already covers the branch is left as it is.
569
+ *
570
+ * @param directory - The checkout to fetch into.
571
+ * @param name - The branch to fetch.
572
+ * @param options - The git runner to use.
573
+ */
574
+ export function fetchBranch(directory: string, name: string, options: GitOptions = {}): void {
575
+ runGitOrThrow(
576
+ [
577
+ "fetch",
578
+ "--quiet",
579
+ UPSTREAM_REMOTE,
580
+ `+refs/heads/${name}:refs/remotes/${UPSTREAM_REMOTE}/${name}`,
581
+ ],
582
+ {
583
+ cwd: directory,
584
+ runner: options.runner,
585
+ what: `Fetching the branch '${name}' from ${UPSTREAM_REMOTE}`,
586
+ }
587
+ );
588
+
589
+ if (fetchConfigCovers(directory, name, options)) return;
590
+
591
+ runGitOrThrow(["remote", "set-branches", "--add", UPSTREAM_REMOTE, name], {
592
+ cwd: directory,
593
+ runner: options.runner,
594
+ what: `Adding the branch '${name}' to the branches ${UPSTREAM_REMOTE} is fetched for`,
595
+ });
596
+ }
597
+
598
+ /**
599
+ * True when the remote's configured fetch refspecs already bring the branch
600
+ * in: a wildcard over every branch, or the branch by name.
601
+ */
602
+ function fetchConfigCovers(directory: string, name: string, options: GitOptions): boolean {
603
+ const runner = options.runner ?? runGit;
604
+ const result = runner(["config", "--get-all", `remote.${UPSTREAM_REMOTE}.fetch`], {
605
+ cwd: directory,
606
+ });
607
+ if (result.status !== 0) return false;
608
+ return result.stdout.split("\n").some((line) => {
609
+ const source = line.trim().replace(/^\+/, "").split(":")[0];
610
+ return source === "refs/heads/*" || source === `refs/heads/${name}`;
611
+ });
612
+ }
613
+
614
+ /**
615
+ * Switches the checkout to an existing branch with `git switch`, which also
616
+ * creates a local branch tracking an upstream one of the same name.
617
+ *
618
+ * @param directory - The checkout to switch.
619
+ * @param name - The branch to switch to.
620
+ * @param options - The git runner to use.
621
+ */
622
+ export function switchBranch(directory: string, name: string, options: GitOptions = {}): void {
623
+ runGitOrThrow(["switch", name], {
624
+ cwd: directory,
625
+ runner: options.runner,
626
+ what: `Switching to the branch '${name}'`,
627
+ });
628
+ }
629
+
630
+ /**
631
+ * Creates a branch at a start point and switches to it, with `git switch
632
+ * --create`, which refuses a branch that already exists. The new branch tracks
633
+ * nothing, so pushing it never lands on the branch it started from.
634
+ *
635
+ * @param directory - The checkout to work in.
636
+ * @param name - The branch to create.
637
+ * @param startPoint - Where it starts, such as `origin/main`.
638
+ * @param options - The git runner to use.
639
+ */
640
+ export function createBranch(
641
+ directory: string,
642
+ name: string,
643
+ startPoint: string,
644
+ options: GitOptions = {}
645
+ ): void {
646
+ runGitOrThrow(["switch", "--create", name, "--no-track", startPoint], {
647
+ cwd: directory,
648
+ runner: options.runner,
649
+ what: `Creating the branch '${name}' from ${startPoint}`,
650
+ });
651
+ }
652
+
653
+ /** The local work that making a branch match upstream would throw away. */
654
+ export type DiscardableWork = {
655
+ /** Changes to tracked files that are not committed, in `git status --short` form. */
656
+ uncommitted: string[];
657
+ /** Commits on the local branch that its upstream counterpart lacks, one line each. */
658
+ localCommits: string[];
659
+ };
660
+
661
+ /**
662
+ * Lists what `resetBranchToUpstream` would discard: uncommitted changes to
663
+ * tracked files (untracked files are left alone by it, so they are not listed),
664
+ * and commits on the local branch that the fetched upstream branch lacks. Run
665
+ * it after fetching the branch.
666
+ *
667
+ * @param directory - The checkout to inspect.
668
+ * @param name - The branch that would be made to match upstream.
669
+ * @param options - The git runner to use.
670
+ */
671
+ export function discardableWork(
672
+ directory: string,
673
+ name: string,
674
+ options: GitOptions = {}
675
+ ): DiscardableWork {
676
+ const status = runGitOrThrow(["status", "--porcelain", "--untracked-files=no"], {
677
+ cwd: directory,
678
+ runner: options.runner,
679
+ what: "Listing the uncommitted changes",
680
+ });
681
+ const uncommitted = status.stdout
682
+ .split("\n")
683
+ .map((line) => line.trimEnd())
684
+ .filter((line) => line.length > 0);
685
+
686
+ let localCommits: string[] = [];
687
+ if (localBranchExists(directory, name, options)) {
688
+ const log = runGitOrThrow(
689
+ ["log", "--oneline", "--no-decorate", `refs/remotes/${UPSTREAM_REMOTE}/${name}..refs/heads/${name}`],
690
+ {
691
+ cwd: directory,
692
+ runner: options.runner,
693
+ what: `Listing the commits on '${name}' that ${UPSTREAM_REMOTE} does not have`,
694
+ }
695
+ );
696
+ localCommits = log.stdout
697
+ .split("\n")
698
+ .map((line) => line.trim())
699
+ .filter((line) => line.length > 0);
700
+ }
701
+
702
+ return { uncommitted, localCommits };
703
+ }
704
+
705
+ /**
706
+ * Switches to a branch and makes it match its fetched upstream counterpart
707
+ * exactly, creating the local branch when there is none. Uncommitted changes to
708
+ * tracked files and local commits upstream lacks are discarded, which is why a
709
+ * caller lists them with `discardableWork` and asks first. Every other branch
710
+ * is left as it is.
711
+ *
712
+ * @param directory - The checkout to work in.
713
+ * @param name - The branch to update.
714
+ * @param options - The git runner to use.
715
+ */
716
+ export function resetBranchToUpstream(
717
+ directory: string,
718
+ name: string,
719
+ options: GitOptions = {}
720
+ ): void {
721
+ runGitOrThrow(
722
+ ["switch", "--discard-changes", "--force-create", name, `${UPSTREAM_REMOTE}/${name}`],
723
+ {
724
+ cwd: directory,
725
+ runner: options.runner,
726
+ what: `Making the branch '${name}' match ${UPSTREAM_REMOTE}/${name}`,
727
+ }
728
+ );
729
+ }
730
+
731
+ /**
732
+ * The name `--generate-branch` gives a new branch: `sous/edit-<YYYYMMDD>-<HHMM>`,
733
+ * in local time.
734
+ *
735
+ * @param now - The moment to name it after. Defaults to now.
736
+ */
737
+ export function generatedBranchName(now: Date = new Date()): string {
738
+ const pad = (value: number) => String(value).padStart(2, "0");
739
+ const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
740
+ const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
741
+ return `sous/edit-${date}-${time}`;
742
+ }
743
+
283
744
  // --- Small filesystem helpers -------------------------------------------------------------------
284
745
 
285
746
  /** Replaces anything outside a safe path segment, so a URL can never escape the store. */