guito 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to Guito are documented in this file. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and this changelog is structured around [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
4
 
5
+ ## [0.6.2] - 2026-09-09
6
+
7
+ ### Added
8
+
9
+ - Show author avatars in the commit table and the commit detail pane. Avatars are looked up by the author email on Gravatar, cached for a day, and fall back to initials when no image is available.
10
+ - Refresh Guito automatically when the repository changes. A lightweight probe runs every few seconds and whenever the window regains focus, detects commits, branches, tags, configuration, mailmap, and working tree changes made outside Guito (for example from a terminal or another tool), and then refreshes the history and the Uncommitted changes panel. History pages that were already loaded are kept, and the selected branch and commit stay valid after the refresh.
11
+ - Resize the Description column of the commit table by dragging, like the other columns. The chosen width is persisted across sessions together with the other column widths.
12
+ - Create draft pull requests with a "Create as draft" option in the Create Pull Request dialog.
13
+ - Move the commit search to the far right of the Guito title bar. The search box with its clear button, match counter, and previous/next navigation now sits next to the title instead of in the toolbar.
14
+ - Add the guito.autoReload setting (enabled by default) that controls whether Guito refreshes automatically as described above; it can also be set per repository in the server-side settings file.
15
+
16
+ ### Changed
17
+
18
+ - Fill the window width with the commit table on load and when the window is resized: the Description column widens to absorb spare room, while resizing a column changes only that column and may leave the table narrower until the window is resized again. The saved widths act as minimums on narrow windows.
19
+ - The Uncommitted changes row shows your configured Git user name instead of "You", and commits display the author name from your Git configuration when the commit email matches it, while other authors and mailmap-rewritten names are left untouched.
20
+ - Tag suggestions in the Create Pull Request dialog now list labels already used by the repository's pull requests instead of Azure Boards work item tags.
21
+ - Reviewer, work item, and tag lookups in the Create Pull Request dialog show their own loading state and error message, cancel outdated requests while typing, and show a "No matches" hint instead of an empty list.
22
+
23
+ ### Fixed
24
+
25
+ - Build pull request and pull request label URLs through the Azure DevOps repositories API so they resolve on Azure DevOps Server.
26
+ - Link work items to pull requests using the refs format Azure DevOps expects, and scope work item search to the current team project, accepting `#id` queries and returning at most 20 results.
27
+ - Strip `origin/` and `refs/heads/` prefixes from the pull request target branch before creating the pull request.
28
+ - Build Azure DevOps base URLs without duplicating collection path segments when the server URL path overlaps the remote repository path.
29
+ - Search reviewers through the collection identities API so the returned identities can be used directly as pull request reviewers.
30
+
5
31
  ## [0.6.1] - 2026-09-08
6
32
 
7
33
  ### Added
package/bin/avatars.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function avatarCache(fetchImage = fetch) {
3
+ const entries = new Map();
4
+ return (email) => {
5
+ const hash = createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
6
+ const cached = entries.get(hash);
7
+ if (cached && cached.expires > Date.now())
8
+ return cached.image;
9
+ if (entries.size >= 500)
10
+ entries.delete(entries.keys().next().value);
11
+ const entry = { expires: Date.now() + 86400000, image: Promise.resolve(null) };
12
+ entry.image = (async () => {
13
+ try {
14
+ const response = await fetchImage(`https://www.gravatar.com/avatar/${hash}?s=64&d=404`, {
15
+ signal: AbortSignal.timeout(5000),
16
+ });
17
+ if (response.status === 404)
18
+ return null;
19
+ if (!response.ok || !/^image\/(png|jpeg|gif|webp)/i.test(response.headers.get('content-type') ?? '')) {
20
+ throw new Error('Avatar unavailable');
21
+ }
22
+ const buffer = Buffer.from(await response.arrayBuffer());
23
+ if (buffer.length > 256000)
24
+ throw new Error('Avatar too large');
25
+ return { data: buffer, contentType: response.headers.get('content-type').split(';')[0] };
26
+ }
27
+ catch {
28
+ entry.expires = Date.now() + 60000;
29
+ return null;
30
+ }
31
+ })();
32
+ entries.set(hash, entry);
33
+ return entry.image;
34
+ };
35
+ }
@@ -10,8 +10,10 @@ import fastifyCompress from '@fastify/compress';
10
10
  import { simpleGit } from 'simple-git';
11
11
  import { promisify } from 'node:util';
12
12
  import { workingTree } from './working-tree.js';
13
+ import { avatarCache } from './avatars.js';
14
+ import { repositoryState } from './repository-state.js';
13
15
  const execFileAsync = promisify(execFile);
14
- const AZURE_API_VERSION = '4.1';
16
+ const AZURE_API_VERSION = '5.0';
15
17
  /**
16
18
  * Extracts {projectPath, repo} from an Azure DevOps git remote URL.
17
19
  * Supports https (with optional credentials) and ssh (scp-style and ssh://)
@@ -74,11 +76,19 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
74
76
  .join('/');
75
77
  try {
76
78
  const parsed = new URL(base);
77
- const basePath = parsed.pathname.replace(/^\/+|\/+$/g, '');
78
- if (basePath && info.projectPath.toLowerCase().startsWith(basePath.toLowerCase())) {
79
- return `${parsed.origin}/${encodePath(info.projectPath)}`;
80
- }
81
- return `${parsed.origin}${basePath ? `/${encodePath(basePath)}` : ''}/${encodePath(info.projectPath)}`;
79
+ const decode = (path) => path
80
+ .split('/')
81
+ .filter(Boolean)
82
+ .map((segment) => decodeURIComponent(segment));
83
+ const baseParts = decode(parsed.pathname);
84
+ const remoteParts = info.projectPath.split('/').filter(Boolean);
85
+ let overlap = Math.min(baseParts.length, remoteParts.length);
86
+ while (overlap &&
87
+ !baseParts
88
+ .slice(-overlap)
89
+ .every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
90
+ overlap--;
91
+ return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join('/')}`;
82
92
  }
83
93
  catch {
84
94
  return `${base}/${encodePath(info.projectPath)}`;
@@ -94,7 +104,7 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
94
104
  if (!info) {
95
105
  return null;
96
106
  }
97
- return `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
107
+ return `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
98
108
  }
99
109
  /**
100
110
  * Default Azure DevOps HTTP runner: shells out to the Windows built-in
@@ -170,7 +180,7 @@ const azureErrorMessage = (result) => {
170
180
  return result.body;
171
181
  }
172
182
  };
173
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
183
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, autoReload, azureRequestImpl, avatarFetchImpl, }) {
174
184
  // Initialize server
175
185
  const app = fastify({
176
186
  logger: false,
@@ -207,6 +217,27 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
207
217
  }
208
218
  // Initialize git lib
209
219
  const git = simpleGit(repositoryPath);
220
+ const avatars = avatarCache(avatarFetchImpl);
221
+ app.get('/api/avatar', async (req, reply) => {
222
+ const email = String(req.query?.email ?? '').trim();
223
+ if (!email || email.length > 320)
224
+ return reply.code(400).send({ error: 'Valid email required' });
225
+ const image = await avatars(email);
226
+ reply.header('Cache-Control', 'private, max-age=3600');
227
+ return image ? reply.type(image.contentType).send(image.data) : reply.code(204).send();
228
+ });
229
+ let stateRequest;
230
+ app.get('/api/repository-state', async (_req, reply) => {
231
+ try {
232
+ stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
233
+ stateRequest = undefined;
234
+ }));
235
+ return await stateRequest;
236
+ }
237
+ catch (err) {
238
+ return reply.code(400).send({ error: err.message });
239
+ }
240
+ });
210
241
  // ==================== Diff parsing ====================
211
242
  function parseUnifiedDiff(rawDiff) {
212
243
  const files = [];
@@ -350,11 +381,36 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
350
381
  }
351
382
  return { prefix, remoteUrl };
352
383
  };
384
+ /** Merges the VS Code settings with the server-side settings file. */
385
+ const effectiveSettings = async () => {
386
+ const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
387
+ const fileAutoReload = typeof file.autoReload === 'boolean' ? file.autoReload : undefined;
388
+ return {
389
+ azureDevOpsUrl: azure.url,
390
+ source: azure.source,
391
+ autoReload: typeof autoReload === 'boolean' ? autoReload : (fileAutoReload ?? true),
392
+ };
393
+ };
394
+ const configuredIdentity = async () => {
395
+ const config = await git.listConfig();
396
+ return {
397
+ name: String(config.all['user.name'] ?? '').trim(),
398
+ email: String(config.all['user.email'] ?? '').trim(),
399
+ };
400
+ };
401
+ const authorIdentity = (name, email, identity) => ({
402
+ author_name: identity.name && identity.email && email.trim().toLowerCase() === identity.email.toLowerCase()
403
+ ? identity.name
404
+ : name,
405
+ author_email: email,
406
+ });
353
407
  // ==================== Repository ====================
354
408
  app.get('/api/repo', async (_req, resp) => {
355
409
  try {
356
410
  const root = (await git.revparse(['--show-toplevel'])).trim();
357
- return resp.type('application/json').send({ root, name: basename(root) });
411
+ return resp
412
+ .type('application/json')
413
+ .send({ root, name: basename(root), identity: await configuredIdentity() });
358
414
  }
359
415
  catch (err) {
360
416
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -389,12 +445,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
389
445
  if (Number.isFinite(skip) && skip > 0) {
390
446
  options['--skip'] = String(skip);
391
447
  }
392
- const [log, count] = await Promise.all([
448
+ const [log, count, identity] = await Promise.all([
393
449
  git.log(options),
394
450
  git.raw(['rev-list', '--count', '--all']),
451
+ configuredIdentity(),
395
452
  ]);
396
453
  const commits = log.all.map((commit) => ({
397
454
  ...commit,
455
+ ...authorIdentity(commit.author_name, commit.author_email, identity),
398
456
  parents: String(commit.parents ?? '')
399
457
  .split(' ')
400
458
  .filter(Boolean),
@@ -854,10 +912,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
854
912
  // ==================== Settings ====================
855
913
  app.get('/api/settings', async (_req, resp) => {
856
914
  try {
857
- const effective = await effectiveAzureUrl();
858
- return resp
859
- .type('application/json')
860
- .send({ azureDevOpsUrl: effective.url, source: effective.source });
915
+ const settings = await effectiveSettings();
916
+ return resp.type('application/json').send(settings);
861
917
  }
862
918
  catch (err) {
863
919
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -875,10 +931,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
875
931
  const settings = await readSettings();
876
932
  settings.azureDevOpsUrl = url;
877
933
  await writeSettings(settings);
878
- const effective = await effectiveAzureUrl();
879
- return resp
880
- .type('application/json')
881
- .send({ azureDevOpsUrl: effective.url, source: effective.source });
934
+ const effective = await effectiveSettings();
935
+ return resp.type('application/json').send(effective);
882
936
  }
883
937
  catch (err) {
884
938
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -899,7 +953,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
899
953
  .type('application/json')
900
954
  .send({ error: 'Unable to build the Azure DevOps pull request URL.' });
901
955
  }
902
- const prUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
956
+ const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
903
957
  let source = String(sourceBranch ?? '').trim();
904
958
  if (newBranch) {
905
959
  // Create a remote-only branch from the current HEAD; the local
@@ -936,8 +990,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
936
990
  }
937
991
  }
938
992
  const payload = {
993
+ isDraft: req.body?.isDraft === true,
939
994
  sourceRefName: `refs/heads/${source}`,
940
- targetRefName: `refs/heads/${targetBranch}`,
995
+ targetRefName: `refs/heads/${String(targetBranch)
996
+ .replace(/^refs\/heads\//, '')
997
+ .replace(/^origin\//, '')}`,
941
998
  };
942
999
  const trimmedTitle = String(title ?? '').trim();
943
1000
  const trimmedDescription = String(description ?? '').trim();
@@ -959,7 +1016,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
959
1016
  .map((id) => Number(id))
960
1017
  .filter((id) => Number.isInteger(id) && id > 0);
961
1018
  if (payloadWorkItems.length) {
962
- payload.workItems = payloadWorkItems.map((id) => ({ id }));
1019
+ payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
963
1020
  }
964
1021
  const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
965
1022
  if (result.status < 200 || result.status >= 300) {
@@ -978,7 +1035,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
978
1035
  const warnings = [];
979
1036
  if (labelNames.length && created.pullRequestId) {
980
1037
  // Built from the prefix: prUrl already carries the api-version query.
981
- const labelsUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}` +
1038
+ const labelsUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}` +
982
1039
  `/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
983
1040
  for (const name of labelNames) {
984
1041
  try {
@@ -1004,8 +1061,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1004
1061
  }
1005
1062
  });
1006
1063
  // ==================== Azure DevOps PR metadata ====================
1007
- // Identity search for the reviewer pickers (the endpoint the Azure DevOps
1008
- // web UI itself uses; undocumented but stable across TFS 2018+).
1064
+ // Collection-scoped identity IDs can be passed directly as PR reviewers.
1009
1065
  app.get('/api/azure-devops/reviewers', async (req, resp) => {
1010
1066
  try {
1011
1067
  const query = String((req.query ?? {}).query ?? '').trim();
@@ -1013,9 +1069,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1013
1069
  return resp.type('application/json').send({ reviewers: [] });
1014
1070
  }
1015
1071
  const { prefix } = await azureProjectContext();
1016
- const url = `${prefix}/_apis/identitypicker/identities?api-version=${AZURE_API_VERSION}` +
1017
- '&identityTypes=msa-uuid,umd&queryType=nameAndEmail&searchFilter=' +
1018
- `&showImage=False&filterValue=${encodeURIComponent(query)}`;
1072
+ const collection = prefix.slice(0, prefix.lastIndexOf('/'));
1073
+ const identityBase = collection.replace('https://dev.azure.com/', 'https://vssps.dev.azure.com/');
1074
+ const url = `${identityBase}/_apis/identities?api-version=5.0` +
1075
+ `&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
1019
1076
  const result = await azureRequest('GET', url, '');
1020
1077
  if (result.status < 200 || result.status >= 300) {
1021
1078
  return resp
@@ -1026,12 +1083,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1026
1083
  });
1027
1084
  }
1028
1085
  const data = JSON.parse(result.body);
1029
- const list = Array.isArray(data) ? data : (data.results ?? data.identities ?? []);
1086
+ const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
1030
1087
  const reviewers = list
1031
1088
  .map((entry) => ({
1032
1089
  id: String(entry?.id ?? entry?.identity?.id ?? ''),
1033
- label: String(entry?.label ?? entry?.displayName ?? entry?.name ?? ''),
1034
- description: entry?.description ? String(entry.description) : undefined,
1090
+ label: String(entry?.customDisplayName ||
1091
+ entry?.providerDisplayName ||
1092
+ entry?.label ||
1093
+ entry?.displayName ||
1094
+ entry?.name ||
1095
+ ''),
1096
+ description: entry?.properties?.Mail?.$value ??
1097
+ entry?.properties?.Account?.$value ??
1098
+ entry?.description ??
1099
+ undefined,
1035
1100
  }))
1036
1101
  .filter((entry) => entry.id && entry.label);
1037
1102
  return resp.type('application/json').send({ reviewers });
@@ -1044,19 +1109,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1044
1109
  // then fetches title/state for the first matches.
1045
1110
  app.get('/api/azure-devops/workitems', async (req, resp) => {
1046
1111
  try {
1047
- const query = String((req.query ?? {}).query ?? '').trim();
1112
+ const query = String((req.query ?? {}).query ?? '')
1113
+ .trim()
1114
+ .replace(/^#(?=\d+$)/, '');
1048
1115
  if (!query) {
1049
1116
  return resp.type('application/json').send({ workItems: [] });
1050
1117
  }
1051
1118
  const { prefix } = await azureProjectContext();
1052
1119
  const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
1053
1120
  if (/^\d+$/.test(query)) {
1054
- clauses.push(`[System.Id] = '${query}'`);
1121
+ clauses.push(`[System.Id] = ${Number(query)}`);
1055
1122
  }
1056
1123
  const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
1057
- `WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
1124
+ `WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
1058
1125
  'ORDER BY [System.ChangedDate] DESC';
1059
- const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1126
+ const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1060
1127
  if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
1061
1128
  return resp
1062
1129
  .status(400)
@@ -1093,22 +1160,46 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1093
1160
  return resp.status(400).type('application/json').send({ error: err.message });
1094
1161
  }
1095
1162
  });
1096
- // Work item tag names for the tag autocomplete.
1163
+ // Existing pull request labels for the tag autocomplete.
1097
1164
  app.get('/api/azure-devops/tags', async (_req, resp) => {
1098
1165
  try {
1099
- const { prefix } = await azureProjectContext();
1100
- const result = await azureRequest('GET', `${prefix}/_apis/wit/tags?api-version=${AZURE_API_VERSION}`, '');
1101
- if (result.status < 200 || result.status >= 300) {
1102
- return resp
1103
- .status(400)
1104
- .type('application/json')
1105
- .send({
1106
- error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
1107
- });
1166
+ const { prefix, remoteUrl } = await azureProjectContext();
1167
+ const repo = parseAzureRemoteUrl(remoteUrl).repo;
1168
+ // PR labels are distinct from Azure Boards work item tags.
1169
+ const names = new Set();
1170
+ for (let skip = 0;; skip += 100) {
1171
+ const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
1172
+ `?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, '');
1173
+ if (result.status < 200 || result.status >= 300) {
1174
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
1175
+ }
1176
+ const data = JSON.parse(result.body);
1177
+ const requests = Array.isArray(data) ? data : (data.value ?? []);
1178
+ // Some Server releases omit labels from the PR list response.
1179
+ // Read those resources explicitly, with at most five requests in flight.
1180
+ for (let offset = 0; offset < requests.length; offset += 5) {
1181
+ const labels = await Promise.all(requests.slice(offset, offset + 5).map(async (request) => {
1182
+ if (Array.isArray(request.labels))
1183
+ return request.labels;
1184
+ if (!Number.isInteger(request.pullRequestId))
1185
+ return [];
1186
+ const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
1187
+ `/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, '');
1188
+ if (result.status < 200 || result.status >= 300) {
1189
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
1190
+ }
1191
+ const data = JSON.parse(result.body);
1192
+ return Array.isArray(data) ? data : (data.value ?? []);
1193
+ }));
1194
+ for (const label of labels.flat()) {
1195
+ if (typeof label.name === 'string' && label.name.trim())
1196
+ names.add(label.name.trim());
1197
+ }
1198
+ }
1199
+ if (requests.length < 100)
1200
+ break;
1108
1201
  }
1109
- const tags = (JSON.parse(result.body).value ?? [])
1110
- .map((tag) => String(tag?.name ?? '').trim())
1111
- .filter(Boolean);
1202
+ const tags = [...names].sort((a, b) => a.localeCompare(b));
1112
1203
  return resp.type('application/json').send({ tags });
1113
1204
  }
1114
1205
  catch (err) {
@@ -1139,8 +1230,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1139
1230
  message,
1140
1231
  refs: '',
1141
1232
  body: body ?? '',
1142
- author_name: authorName,
1143
- author_email: authorEmail,
1233
+ ...authorIdentity(authorName, authorEmail, await configuredIdentity()),
1144
1234
  parents: String(parents ?? '')
1145
1235
  .split(' ')
1146
1236
  .filter(Boolean),
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ // Git resolves the per-worktree HEAD/index and shared refs for us. This also
5
+ // handles atomic ref replacement, packed refs and changes made by other tools.
6
+ export async function repositoryState(git, root) {
7
+ const [refs, head, branch, status, config, mailmap, index] = await Promise.all([
8
+ git.raw(['for-each-ref', '--format=%(refname) %(objectname)']),
9
+ git.revparse(['--verify', 'HEAD']).catch(() => ''),
10
+ git.raw(['symbolic-ref', '--quiet', 'HEAD']).catch(() => ''),
11
+ git.status(),
12
+ git.listConfig(),
13
+ readFile(resolve(root, '.mailmap'), 'utf8').catch(() => ''),
14
+ git.raw(['diff', '--cached', '--raw', '-z', '--no-abbrev']),
15
+ ]);
16
+ const files = await Promise.all(status.files.map(async (file) => {
17
+ const info = await stat(resolve(root, file.path)).catch(() => null);
18
+ return [file, info?.mtimeMs, info?.ctimeMs, info?.size];
19
+ }));
20
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
21
+ return {
22
+ history: digest([refs, head, branch, config.all, mailmap]),
23
+ working: digest([files, index]),
24
+ };
25
+ }
@@ -9,5 +9,5 @@
9
9
  <style>:root{--bg:#1e1e1e;--bg-alt:#232323;--bg-hover:#2a2d2e;--border:#3c3c3c;--text:#cccccc;--text-bright:#e8e8e8;--text-dim:#9d9d9d;--accent:#3794ff;--red:#f14c4c;--mono:ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace}*{box-sizing:border-box}html,body{height:100%;margin:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:13px;color:var(--text);background:var(--bg);overflow:hidden}</style><link rel="stylesheet" href="styles-I7K2YZTW.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-I7K2YZTW.css"></noscript></head>
10
10
  <body>
11
11
  <app-root></app-root>
12
- <script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-SN64JYAQ.js" type="module"></script></body>
12
+ <script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-JDFRJ57Z.js" type="module"></script></body>
13
13
  </html>