@solaqua/gji 0.10.0 → 0.12.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 (122) hide show
  1. package/README.md +83 -426
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +27 -3
  9. package/dist/cli.d.ts +4 -0
  10. package/dist/cli.js +140 -22
  11. package/dist/clone-failure-store.d.ts +18 -0
  12. package/dist/clone-failure-store.js +230 -0
  13. package/dist/command-runner.d.ts +6 -0
  14. package/dist/command-runner.js +38 -0
  15. package/dist/completion.js +7 -2
  16. package/dist/config-command.d.ts +1 -0
  17. package/dist/config-command.js +7 -1
  18. package/dist/config.d.ts +26 -1
  19. package/dist/config.js +110 -6
  20. package/dist/context-card.d.ts +1 -0
  21. package/dist/context-card.js +25 -0
  22. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  23. package/dist/dependency-bootstrap-prompt.js +103 -0
  24. package/dist/dependency-bootstrap.d.ts +129 -0
  25. package/dist/dependency-bootstrap.js +566 -0
  26. package/dist/dir-clone.d.ts +33 -0
  27. package/dist/dir-clone.js +505 -0
  28. package/dist/doctor.js +21 -0
  29. package/dist/done.d.ts +10 -0
  30. package/dist/done.js +210 -0
  31. package/dist/file-sync.js +71 -22
  32. package/dist/format-bytes.d.ts +1 -0
  33. package/dist/format-bytes.js +14 -0
  34. package/dist/fs-utils.d.ts +4 -0
  35. package/dist/fs-utils.js +26 -0
  36. package/dist/git.d.ts +5 -0
  37. package/dist/git.js +50 -0
  38. package/dist/gji-bundle.mjs +6189 -1313
  39. package/dist/go-resolver.d.ts +35 -0
  40. package/dist/go-resolver.js +185 -0
  41. package/dist/go.d.ts +6 -2
  42. package/dist/go.js +270 -62
  43. package/dist/hooks.d.ts +2 -1
  44. package/dist/hooks.js +20 -8
  45. package/dist/hub.d.ts +31 -0
  46. package/dist/hub.js +287 -0
  47. package/dist/init.js +30 -22
  48. package/dist/install-prompt.d.ts +1 -0
  49. package/dist/install-prompt.js +3 -25
  50. package/dist/ls.js +9 -1
  51. package/dist/navigation-output.d.ts +11 -0
  52. package/dist/navigation-output.js +6 -0
  53. package/dist/new.d.ts +14 -2
  54. package/dist/new.js +422 -38
  55. package/dist/pr-open.js +2 -10
  56. package/dist/pr.d.ts +6 -2
  57. package/dist/pr.js +135 -30
  58. package/dist/pull-requests.d.ts +1 -0
  59. package/dist/pull-requests.js +32 -3
  60. package/dist/remove.js +22 -0
  61. package/dist/repo.d.ts +4 -0
  62. package/dist/repo.js +61 -6
  63. package/dist/run-hook.js +2 -0
  64. package/dist/safe-destination.d.ts +15 -0
  65. package/dist/safe-destination.js +166 -0
  66. package/dist/shell-completion.js +52 -13
  67. package/dist/slots.d.ts +8 -0
  68. package/dist/slots.js +61 -0
  69. package/dist/status.d.ts +2 -0
  70. package/dist/status.js +9 -1
  71. package/dist/sync-directories.d.ts +33 -0
  72. package/dist/sync-directories.js +97 -0
  73. package/dist/sync-plan.d.ts +16 -0
  74. package/dist/sync-plan.js +128 -0
  75. package/dist/sync.js +7 -13
  76. package/dist/task-command.d.ts +9 -0
  77. package/dist/task-command.js +29 -0
  78. package/dist/task.d.ts +9 -0
  79. package/dist/task.js +37 -0
  80. package/dist/terminal-text.d.ts +4 -0
  81. package/dist/terminal-text.js +72 -0
  82. package/dist/undo.d.ts +36 -0
  83. package/dist/undo.js +392 -0
  84. package/dist/warp.d.ts +3 -1
  85. package/dist/warp.js +32 -125
  86. package/dist/worktree-bootstrap.d.ts +35 -0
  87. package/dist/worktree-bootstrap.js +96 -0
  88. package/dist/worktree-info.d.ts +6 -1
  89. package/dist/worktree-info.js +14 -4
  90. package/dist/worktree-management.d.ts +1 -0
  91. package/dist/worktree-management.js +23 -4
  92. package/dist/worktree-picker.d.ts +24 -11
  93. package/dist/worktree-picker.js +176 -95
  94. package/dist/worktree-prompts.js +1 -1
  95. package/dist/worktree-source.d.ts +6 -0
  96. package/dist/worktree-source.js +1 -0
  97. package/dist/worktree-sources.d.ts +4 -0
  98. package/dist/worktree-sources.js +77 -0
  99. package/man/man1/gji-back.1 +1 -1
  100. package/man/man1/gji-clean.1 +1 -1
  101. package/man/man1/gji-completion.1 +1 -1
  102. package/man/man1/gji-config.1 +1 -1
  103. package/man/man1/gji-doctor.1 +1 -1
  104. package/man/man1/gji-done.1 +19 -0
  105. package/man/man1/gji-go.1 +12 -3
  106. package/man/man1/gji-history.1 +1 -1
  107. package/man/man1/gji-init.1 +1 -1
  108. package/man/man1/gji-ls.1 +1 -1
  109. package/man/man1/gji-new.1 +21 -3
  110. package/man/man1/gji-open.1 +1 -1
  111. package/man/man1/gji-pr.1 +7 -1
  112. package/man/man1/gji-remove.1 +3 -3
  113. package/man/man1/gji-root.1 +1 -1
  114. package/man/man1/gji-run-hook.1 +1 -1
  115. package/man/man1/gji-status.1 +1 -1
  116. package/man/man1/gji-sync-files.1 +1 -1
  117. package/man/man1/gji-sync.1 +1 -1
  118. package/man/man1/gji-task.1 +16 -0
  119. package/man/man1/gji-undo.1 +16 -0
  120. package/man/man1/gji-warp.1 +3 -6
  121. package/man/man1/gji.1 +17 -5
  122. package/package.json +3 -1
package/dist/done.js ADDED
@@ -0,0 +1,210 @@
1
+ import { basename } from "node:path";
2
+ import { confirm, isCancel } from "@clack/prompts";
3
+ import { loadEffectiveConfig, resolveConfigString } from "./config.js";
4
+ import { isBranchMergedInto, readWorktreeHealth, resolveRemoteDefaultBranch, runGit, } from "./git.js";
5
+ import { isHeadless } from "./headless.js";
6
+ import { loadHistory } from "./history.js";
7
+ import { extractHooks, runHook } from "./hooks.js";
8
+ import { detectRepository, listWorktrees } from "./repo.js";
9
+ import { writeShellOutput } from "./shell-handoff.js";
10
+ import { releaseWorktreeSlot } from "./slots.js";
11
+ import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
12
+ import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, removeWorktree, } from "./worktree-management.js";
13
+ const DONE_OUTPUT_FILE_ENV = "GJI_DONE_OUTPUT_FILE";
14
+ export async function runDoneCommand(options) {
15
+ const repository = await detectRepository(options.cwd);
16
+ if (!repository.isWorktree && !options.branch)
17
+ return doneError(options, "gji done: not inside a linked worktree");
18
+ const linked = (await listWorktrees(options.cwd)).filter((entry) => entry.path !== repository.repoRoot);
19
+ const target = options.branch
20
+ ? linked.find((entry) => entry.branch === options.branch || entry.path === options.branch)
21
+ : linked.find((entry) => entry.path === repository.currentRoot);
22
+ if (!target)
23
+ return doneError(options, options.branch
24
+ ? `gji done: no linked worktree found for ${options.branch}`
25
+ : "gji done: not inside a linked worktree");
26
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
27
+ await refreshUpstream(repository.repoRoot, target.path);
28
+ const health = await readWorktreeHealth(target.path);
29
+ if (target.branch &&
30
+ !options.force &&
31
+ !options.keepBranch &&
32
+ !(await isSafeToComplete(repository.repoRoot, target.branch, health, config))) {
33
+ if (options.json || isHeadless())
34
+ return doneError(options, "branch is not merged and its upstream is not gone; use --force");
35
+ const choice = await confirm({
36
+ message: `branch "${target.branch}" doesn't look merged. Delete anyway?`,
37
+ active: "Yes",
38
+ inactive: "No",
39
+ initialValue: false,
40
+ });
41
+ if (isCancel(choice) || !choice)
42
+ return doneError(options, "Aborted");
43
+ }
44
+ if (health.status === "dirty" && !options.force) {
45
+ if (options.json || isHeadless())
46
+ return doneError(options, "worktree has uncommitted changes; use --force");
47
+ const choice = await confirm({
48
+ message: `worktree ${target.path} has uncommitted changes that cannot be undone. Remove anyway?`,
49
+ active: "Yes",
50
+ inactive: "No",
51
+ initialValue: false,
52
+ });
53
+ if (isCancel(choice) || !choice)
54
+ return doneError(options, "Aborted");
55
+ }
56
+ let journal;
57
+ try {
58
+ journal = await recordUndoOperation("done", repository.repoRoot, [target]);
59
+ }
60
+ catch (error) {
61
+ return doneError(options, `could not write undo journal; no worktree was removed: ${toMessage(error)}`);
62
+ }
63
+ if (!journal)
64
+ return doneError(options, "could not capture undo state; no worktree was removed");
65
+ await runHook(extractHooks(config)["before-remove"], target.path, {
66
+ branch: target.branch ?? undefined,
67
+ path: target.path,
68
+ repo: basename(repository.repoRoot),
69
+ }, options.stderr);
70
+ try {
71
+ await removeWorktree(repository.repoRoot, target.path);
72
+ }
73
+ catch (error) {
74
+ if (!isWorktreeForceRemovalError(error)) {
75
+ await finalizeUndoOperation(journal, []);
76
+ return doneError(options, `Failed to remove worktree at ${target.path}: ${toMessage(error)}`);
77
+ }
78
+ if (!options.force) {
79
+ if (options.json || isHeadless()) {
80
+ await finalizeUndoOperation(journal, []);
81
+ return doneError(options, "worktree requires force removal; use --force");
82
+ }
83
+ const choice = await confirm({
84
+ message: `worktree ${target.path} contains files or initialized submodules that cannot be undone. Force remove?`,
85
+ active: "Yes",
86
+ inactive: "No",
87
+ initialValue: false,
88
+ });
89
+ if (isCancel(choice) || !choice) {
90
+ await finalizeUndoOperation(journal, []);
91
+ return doneError(options, "Aborted");
92
+ }
93
+ }
94
+ try {
95
+ await forceRemoveWorktree(repository.repoRoot, target.path);
96
+ }
97
+ catch (forceError) {
98
+ await finalizeUndoOperation(journal, []);
99
+ return doneError(options, `Failed to remove worktree at ${target.path}: ${toMessage(forceError)}`);
100
+ }
101
+ }
102
+ let branchDeleted = false;
103
+ if (target.branch && !options.keepBranch) {
104
+ try {
105
+ await deleteBranch(repository.repoRoot, target.branch);
106
+ branchDeleted = true;
107
+ }
108
+ catch (error) {
109
+ if (!isBranchUnmergedError(error))
110
+ return doneError(options, `Failed to delete branch ${target.branch}: ${toMessage(error)}`);
111
+ if (options.force) {
112
+ try {
113
+ await forceDeleteBranch(repository.repoRoot, target.branch);
114
+ branchDeleted = true;
115
+ }
116
+ catch (forceError) {
117
+ return doneError(options, `Failed to delete branch ${target.branch}: ${toMessage(forceError)}`);
118
+ }
119
+ }
120
+ else if (!options.json) {
121
+ options.stderr(`Branch ${target.branch} was not deleted (has unmerged commits)\n`);
122
+ }
123
+ }
124
+ }
125
+ await finalizeUndoOperation(journal, [target]);
126
+ await releaseWorktreeSlot(target.path);
127
+ await runGit(repository.repoRoot, ["worktree", "prune"]).catch(() => undefined);
128
+ const shouldMove = options.branch === undefined || target.path === repository.currentRoot;
129
+ const movedTo = shouldMove
130
+ ? await resolveDoneDestination(repository.repoRoot, target.path)
131
+ : null;
132
+ if (!options.json && movedTo)
133
+ await writeShellOutput(DONE_OUTPUT_FILE_ENV, movedTo, options.stdout);
134
+ else if (!options.json && options.branch && process.env[DONE_OUTPUT_FILE_ENV])
135
+ await writeShellOutput(DONE_OUTPUT_FILE_ENV, options.cwd, options.stdout);
136
+ if (options.json)
137
+ options.stdout(`${JSON.stringify({ branch: target.branch, path: target.path, deleted: true, branchDeleted, movedTo }, null, 2)}\n`);
138
+ else
139
+ options.stderr(`✓ removed ${target.branch ?? "detached worktree"} (worktree${branchDeleted ? " + branch" : ""})${movedTo ? ` → back at ${movedTo}` : ""} · undo: gji undo\n`);
140
+ return 0;
141
+ }
142
+ async function isSafeToComplete(repoRoot, branch, health, config) {
143
+ if (health.upstreamGone)
144
+ return true;
145
+ const remote = resolveConfigString(config, "syncRemote") ?? "origin";
146
+ const configuredDefault = resolveConfigString(config, "syncDefaultBranch");
147
+ const remoteDefault = configuredDefault ??
148
+ (await resolveRemoteDefaultBranch(repoRoot, remote).catch(() => null));
149
+ if (remoteDefault) {
150
+ for (const base of [`${remote}/${remoteDefault}`, remoteDefault]) {
151
+ if (await gitRefExists(repoRoot, base))
152
+ return isBranchMergedInto(repoRoot, branch, base).catch(() => false);
153
+ }
154
+ return false;
155
+ }
156
+ const base = await runGit(repoRoot, [
157
+ "symbolic-ref",
158
+ "--short",
159
+ "HEAD",
160
+ ]).catch(() => null);
161
+ return base === null
162
+ ? false
163
+ : isBranchMergedInto(repoRoot, branch, base).catch(() => false);
164
+ }
165
+ async function gitRefExists(repoRoot, ref) {
166
+ try {
167
+ await runGit(repoRoot, ["rev-parse", "--verify", ref]);
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ async function refreshUpstream(repoRoot, worktreePath) {
175
+ const upstream = await runGit(worktreePath, [
176
+ "rev-parse",
177
+ "--abbrev-ref",
178
+ "--symbolic-full-name",
179
+ "@{u}",
180
+ ]).catch(() => null);
181
+ const remote = upstream?.split("/")[0];
182
+ if (!remote)
183
+ return;
184
+ await runGit(repoRoot, ["fetch", "--prune", "--quiet", remote]).catch(() => undefined);
185
+ }
186
+ async function resolveDoneDestination(repoRoot, removedPath) {
187
+ const history = await loadHistory();
188
+ for (const entry of history) {
189
+ if (entry.path !== removedPath && entry.path !== repoRoot) {
190
+ try {
191
+ await runGit(entry.path, ["rev-parse", "--show-toplevel"]);
192
+ return entry.path;
193
+ }
194
+ catch {
195
+ /* stale history */
196
+ }
197
+ }
198
+ }
199
+ return repoRoot;
200
+ }
201
+ function doneError(options, message) {
202
+ if (options.json)
203
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
204
+ else
205
+ options.stderr(`${message}\n`);
206
+ return 1;
207
+ }
208
+ function toMessage(error) {
209
+ return error instanceof Error ? error.message : String(error);
210
+ }
package/dist/file-sync.js CHANGED
@@ -1,5 +1,8 @@
1
- import { copyFile, mkdir, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, normalize } from "node:path";
1
+ import { constants } from "node:fs";
2
+ import { copyFile, lstat, realpath } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep, } from "node:path";
4
+ import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js";
5
+ import { inspectDestination, openDestinationDirectory, } from "./safe-destination.js";
3
6
  /**
4
7
  * Copies files matching each pattern (relative to mainRoot) into the equivalent
5
8
  * relative path under targetPath, creating parent directories as needed.
@@ -11,20 +14,43 @@ import { dirname, isAbsolute, join, normalize } from "node:path";
11
14
  export async function syncFiles(mainRoot, targetPath, patterns) {
12
15
  for (const pattern of patterns) {
13
16
  const normalized = validateSyncFilePattern(pattern);
14
- const sourcePath = join(mainRoot, normalized);
15
- const destPath = join(targetPath, normalized);
16
- // Skip silently if source does not exist
17
- const sourceExists = await fileExists(sourcePath);
18
- if (!sourceExists) {
17
+ const sourcePath = await resolveSyncFileSource(mainRoot, normalized);
18
+ if (!sourcePath)
19
19
  continue;
20
+ const destPath = join(targetPath, normalized);
21
+ // Skip ordinary existing targets, but fail closed on any symlink.
22
+ const existingDestination = await readDestinationEntry(destPath);
23
+ if (existingDestination?.isSymbolicLink()) {
24
+ throw new Error(`destination is a symbolic link: ${destPath}`);
20
25
  }
21
- // Skip silently if target already exists
22
- const destExists = await fileExists(destPath);
23
- if (destExists) {
26
+ if (existingDestination) {
24
27
  continue;
25
28
  }
26
- await mkdir(dirname(destPath), { recursive: true });
27
- await copyFile(sourcePath, destPath);
29
+ const destinationParent = dirname(destPath);
30
+ const beforeCreate = await inspectDestination(targetPath, destinationParent);
31
+ if (beforeCreate.kind === "unsafe") {
32
+ throw new Error(beforeCreate.reason);
33
+ }
34
+ const safeParent = await openDestinationDirectory(targetPath, destinationParent);
35
+ try {
36
+ const safeDestination = join(safeParent.path, basename(destPath));
37
+ const safeExistingDestination = await readDestinationEntry(safeDestination);
38
+ if (safeExistingDestination?.isSymbolicLink()) {
39
+ throw new Error(`destination is a symbolic link: ${destPath}`);
40
+ }
41
+ if (safeExistingDestination)
42
+ continue;
43
+ try {
44
+ await copyFile(sourcePath, safeDestination, constants.COPYFILE_EXCL);
45
+ }
46
+ catch (error) {
47
+ if (!isAlreadyExistsError(error))
48
+ throw error;
49
+ }
50
+ }
51
+ finally {
52
+ await safeParent.close().catch(() => undefined);
53
+ }
28
54
  }
29
55
  }
30
56
  export function validateSyncFilePattern(pattern) {
@@ -37,20 +63,43 @@ export function validateSyncFilePattern(pattern) {
37
63
  }
38
64
  return normalized;
39
65
  }
40
- async function fileExists(path) {
66
+ async function resolveSyncFileSource(mainRoot, pattern) {
67
+ let resolvedRoot;
68
+ let resolvedSource;
41
69
  try {
42
- await stat(path);
43
- return true;
70
+ [resolvedRoot, resolvedSource] = await Promise.all([
71
+ realpath(mainRoot),
72
+ realpath(join(mainRoot, pattern)),
73
+ ]);
44
74
  }
45
75
  catch (error) {
46
- if (isNotFoundError(error)) {
47
- return false;
48
- }
76
+ if (isNotFoundError(error))
77
+ return undefined;
78
+ throw error;
79
+ }
80
+ if (!isPathInside(resolvedRoot, resolvedSource)) {
81
+ throw new Error(`syncFiles: source symlink resolves outside the repository: ${resolvedSource}`);
82
+ }
83
+ const sourceStats = await lstat(resolvedSource);
84
+ if (!sourceStats.isFile()) {
85
+ throw new Error(`syncFiles: source is not a file: ${join(mainRoot, pattern)}`);
86
+ }
87
+ return resolvedSource;
88
+ }
89
+ async function readDestinationEntry(path) {
90
+ try {
91
+ return await lstat(path);
92
+ }
93
+ catch (error) {
94
+ if (isNotFoundError(error))
95
+ return undefined;
49
96
  throw error;
50
97
  }
51
98
  }
52
- function isNotFoundError(error) {
53
- return (error instanceof Error &&
54
- "code" in error &&
55
- error.code === "ENOENT");
99
+ function isPathInside(root, candidate) {
100
+ const distance = relative(resolve(root), resolve(candidate));
101
+ return (distance === "" ||
102
+ (!isAbsolute(distance) &&
103
+ distance !== ".." &&
104
+ !distance.startsWith(`..${sep}`)));
56
105
  }
@@ -0,0 +1 @@
1
+ export declare function formatBytes(bytes: number | undefined): string;
@@ -0,0 +1,14 @@
1
+ export function formatBytes(bytes) {
2
+ if (bytes === undefined)
3
+ return "size unavailable";
4
+ if (bytes < 1024)
5
+ return `${bytes} B`;
6
+ const units = ["KB", "MB", "GB", "TB"];
7
+ let value = bytes;
8
+ let unit = -1;
9
+ while (value >= 1024 && unit < units.length - 1) {
10
+ value /= 1024;
11
+ unit += 1;
12
+ }
13
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
14
+ }
@@ -0,0 +1,4 @@
1
+ export declare function pathExists(path: string): Promise<boolean>;
2
+ export declare function isAlreadyExistsError(error: unknown): boolean;
3
+ export declare function isNotDirectoryError(error: unknown): boolean;
4
+ export declare function isNotFoundError(error: unknown): boolean;
@@ -0,0 +1,26 @@
1
+ import { lstat } from "node:fs/promises";
2
+ export async function pathExists(path) {
3
+ try {
4
+ await lstat(path);
5
+ return true;
6
+ }
7
+ catch (error) {
8
+ if (isNotFoundError(error))
9
+ return false;
10
+ throw error;
11
+ }
12
+ }
13
+ export function isAlreadyExistsError(error) {
14
+ return (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ERR_FS_CP_EEXIST"));
15
+ }
16
+ export function isNotDirectoryError(error) {
17
+ return hasErrorCode(error, "ENOTDIR");
18
+ }
19
+ export function isNotFoundError(error) {
20
+ return hasErrorCode(error, "ENOENT");
21
+ }
22
+ function hasErrorCode(error, code) {
23
+ return (error instanceof Error &&
24
+ "code" in error &&
25
+ error.code === code);
26
+ }
package/dist/git.d.ts CHANGED
@@ -5,9 +5,14 @@ export interface WorktreeHealth {
5
5
  upstreamGone: boolean;
6
6
  status: "clean" | "dirty";
7
7
  }
8
+ export interface RemoteBase {
9
+ branch: string;
10
+ ref: string;
11
+ }
8
12
  export declare function runGit(cwd: string, args: string[]): Promise<string>;
9
13
  export declare function readWorktreeHealth(cwd: string): Promise<WorktreeHealth>;
10
14
  export declare function isDirtyWorktree(cwd: string): Promise<boolean>;
11
15
  export declare function isBranchMergedInto(cwd: string, branch: string, base?: string): Promise<boolean>;
12
16
  export declare function resolveRemoteDefaultBranch(cwd: string, remote: string): Promise<string | null>;
17
+ export declare function resolveRemoteBase(cwd: string, remote: string, configuredBranch?: string): Promise<RemoteBase | null>;
13
18
  export declare function readBranchLastCommitTimestamp(cwd: string, branch: string): Promise<number | null>;
package/dist/git.js CHANGED
@@ -44,6 +44,56 @@ export async function resolveRemoteDefaultBranch(cwd, remote) {
44
44
  const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(refLine);
45
45
  return match?.[1] ?? null;
46
46
  }
47
+ export async function resolveRemoteBase(cwd, remote, configuredBranch) {
48
+ try {
49
+ await runGit(cwd, ["remote", "get-url", remote]);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ let branch = configuredBranch;
55
+ if (!branch) {
56
+ try {
57
+ const discoveredBranch = await resolveRemoteDefaultBranch(cwd, remote);
58
+ branch =
59
+ discoveredBranch ??
60
+ (await resolveCachedRemoteDefaultBranch(cwd, remote));
61
+ }
62
+ catch {
63
+ branch = await resolveCachedRemoteDefaultBranch(cwd, remote);
64
+ }
65
+ }
66
+ if (!branch)
67
+ return null;
68
+ return {
69
+ branch,
70
+ ref: `${remote}/${branch}`,
71
+ };
72
+ }
73
+ async function resolveCachedRemoteDefaultBranch(cwd, remote) {
74
+ try {
75
+ const ref = await runGit(cwd, [
76
+ "symbolic-ref",
77
+ "--short",
78
+ `refs/remotes/${remote}/HEAD`,
79
+ ]);
80
+ const prefix = `${remote}/`;
81
+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : undefined;
82
+ }
83
+ catch {
84
+ try {
85
+ const refs = await runGit(cwd, [
86
+ "for-each-ref",
87
+ "--format=%(refname:strip=3)",
88
+ `refs/remotes/${remote}`,
89
+ ]);
90
+ return refs.split("\n").find((ref) => ref !== "HEAD");
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ }
47
97
  export async function readBranchLastCommitTimestamp(cwd, branch) {
48
98
  try {
49
99
  const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%ct", branch], { cwd });