guito 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { execFile } from 'node:child_process';
3
- import { readFile, rm } from 'node:fs/promises';
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { randomBytes } from 'node:crypto';
4
5
  import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
6
  import fastify from 'fastify';
6
7
  import fastifyStatic from '@fastify/static';
@@ -8,8 +9,168 @@ import fastifyCors from '@fastify/cors';
8
9
  import fastifyCompress from '@fastify/compress';
9
10
  import { simpleGit } from 'simple-git';
10
11
  import { promisify } from 'node:util';
12
+ import { workingTree } from './working-tree.js';
11
13
  const execFileAsync = promisify(execFile);
12
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, }) {
14
+ const AZURE_API_VERSION = '4.1';
15
+ /**
16
+ * Extracts {projectPath, repo} from an Azure DevOps git remote URL.
17
+ * Supports https (with optional credentials) and ssh (scp-style and ssh://)
18
+ * forms, a trailing .git, and URL-encoded segments such as %20.
19
+ */
20
+ export function parseAzureRemoteUrl(remoteUrl) {
21
+ const value = (remoteUrl ?? '').trim();
22
+ if (!value) {
23
+ return null;
24
+ }
25
+ let path;
26
+ const urlMatch = value.match(/^[a-z][a-z0-9+.\-]*:\/\/[^/]*\/(.*)$/i);
27
+ if (urlMatch) {
28
+ path = urlMatch[1];
29
+ }
30
+ else {
31
+ // scp-style ssh: git@host:Collection/Project/_git/Repo(.git)
32
+ const scpMatch = value.match(/^[^@/]+@[^:]+:(.+)$/);
33
+ if (!scpMatch) {
34
+ return null;
35
+ }
36
+ path = scpMatch[1];
37
+ }
38
+ path = path.replace(/\.git\/?$/i, '');
39
+ try {
40
+ path = decodeURIComponent(path);
41
+ }
42
+ catch {
43
+ // Keep the raw path when it contains invalid percent-escapes.
44
+ }
45
+ const gitIndex = path.toLowerCase().lastIndexOf('/_git/');
46
+ if (gitIndex < 0) {
47
+ return null;
48
+ }
49
+ const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, '');
50
+ const repo = path.slice(gitIndex + '/_git/'.length).replace(/^\/+|\/+$/g, '');
51
+ if (!projectPath || !repo) {
52
+ return null;
53
+ }
54
+ return { projectPath, repo };
55
+ }
56
+ /**
57
+ * Builds the encoded "{origin}[/{collection}]/{project}" prefix for Azure
58
+ * DevOps REST calls. When the base URL already contains the collection
59
+ * prefix of the remote path it is not duplicated.
60
+ */
61
+ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
62
+ const base = (baseUrl ?? '').trim().replace(/\/+$/, '');
63
+ if (!base) {
64
+ return null;
65
+ }
66
+ const info = parseAzureRemoteUrl(remoteUrl);
67
+ if (!info) {
68
+ return null;
69
+ }
70
+ const encodePath = (value) => value
71
+ .split('/')
72
+ .filter(Boolean)
73
+ .map((segment) => encodeURIComponent(segment))
74
+ .join('/');
75
+ try {
76
+ 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)}`;
82
+ }
83
+ catch {
84
+ return `${base}/${encodePath(info.projectPath)}`;
85
+ }
86
+ }
87
+ /** Builds the pullrequests REST URL from the server base URL and origin remote. */
88
+ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
89
+ const prefix = buildAzureProjectPrefix(baseUrl, remoteUrl);
90
+ if (!prefix) {
91
+ return null;
92
+ }
93
+ const info = parseAzureRemoteUrl(remoteUrl);
94
+ if (!info) {
95
+ return null;
96
+ }
97
+ return `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
98
+ }
99
+ /**
100
+ * Default Azure DevOps HTTP runner: shells out to the Windows built-in
101
+ * curl.exe with NTLM + Negotiate and an empty username, which authenticates
102
+ * with the current Windows session (SSPI single sign-on).
103
+ */
104
+ function defaultAzureRequest(method, url, body) {
105
+ return new Promise((resolveRequest, rejectRequest) => {
106
+ const args = [
107
+ '-sS',
108
+ '--ntlm',
109
+ '--negotiate',
110
+ '-u',
111
+ ':',
112
+ '--max-time',
113
+ '60',
114
+ '-w',
115
+ '\n%{http_code}',
116
+ '-X',
117
+ method,
118
+ ];
119
+ if (method === 'POST') {
120
+ args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
121
+ }
122
+ args.push(url);
123
+ const child = spawn('curl.exe', args, { windowsHide: true });
124
+ let stdout = '';
125
+ let stderr = '';
126
+ child.stdout.on('data', (chunk) => {
127
+ stdout += chunk;
128
+ });
129
+ child.stderr.on('data', (chunk) => {
130
+ stderr += chunk;
131
+ });
132
+ // EPIPE when curl exits before reading the whole body; the close handler
133
+ // reports the real failure.
134
+ child.stdin.on('error', () => { });
135
+ child.on('error', (err) => {
136
+ rejectRequest(new Error(`Failed to run curl.exe (Windows integrated auth requires Windows): ${err.message}`));
137
+ });
138
+ child.on('close', (code) => {
139
+ if (code !== 0) {
140
+ rejectRequest(new Error(stderr.trim() || `curl.exe exited with code ${code}.`));
141
+ return;
142
+ }
143
+ // -w appends "\n<http_code>" after the response body.
144
+ const separator = stdout.lastIndexOf('\n');
145
+ const status = Number.parseInt(stdout.slice(separator + 1), 10);
146
+ if (separator < 0 || !Number.isFinite(status)) {
147
+ rejectRequest(new Error('Unexpected curl.exe output (missing HTTP status).'));
148
+ return;
149
+ }
150
+ resolveRequest({ status, body: stdout.slice(0, separator) });
151
+ });
152
+ child.stdin.end(body, 'utf8');
153
+ });
154
+ }
155
+ const randomBranchSuffix = () => {
156
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
157
+ const bytes = randomBytes(6);
158
+ let suffix = '';
159
+ for (const byte of bytes) {
160
+ suffix += alphabet[byte % alphabet.length];
161
+ }
162
+ return suffix;
163
+ };
164
+ /** Extracts Azure DevOps' human-readable error message from a REST response. */
165
+ const azureErrorMessage = (result) => {
166
+ try {
167
+ return String(JSON.parse(result.body)?.message ?? '');
168
+ }
169
+ catch {
170
+ return result.body;
171
+ }
172
+ };
173
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
13
174
  // Initialize server
14
175
  const app = fastify({
15
176
  logger: false,
@@ -128,7 +289,67 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
128
289
  files.push(current);
129
290
  return files;
130
291
  }
292
+ const working = workingTree(git, parseUnifiedDiff);
293
+ // Keep validation and the corresponding index mutation in one operation.
294
+ let mutationQueue = Promise.resolve();
295
+ const mutate = (action) => {
296
+ const result = mutationQueue.then(action);
297
+ mutationQueue = result.catch(() => { });
298
+ return result;
299
+ };
131
300
  const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
301
+ // ==================== Azure DevOps settings ====================
302
+ // Settings live in the repository's git directory so they are per-repo and
303
+ // never committed. The VS Code extension passes its own setting via
304
+ // azureDevOpsUrl, which wins over the file.
305
+ let settingsPath = '';
306
+ try {
307
+ const gitDir = (await git.revparse(['--absolute-git-dir'])).trim();
308
+ settingsPath = join(gitDir, 'guito-settings.json');
309
+ }
310
+ catch {
311
+ settingsPath = join(repositoryPath, '.git', 'guito-settings.json');
312
+ }
313
+ const readSettings = async () => {
314
+ try {
315
+ return JSON.parse(await readFile(settingsPath, 'utf8'));
316
+ }
317
+ catch {
318
+ return {};
319
+ }
320
+ };
321
+ const writeSettings = async (settings) => {
322
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
323
+ };
324
+ const azureRequest = azureRequestImpl ?? defaultAzureRequest;
325
+ const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
326
+ const effectiveAzureUrl = async () => {
327
+ if (configuredAzureUrl) {
328
+ return { url: configuredAzureUrl, source: 'vscode' };
329
+ }
330
+ const settings = await readSettings();
331
+ const url = typeof settings.azureDevOpsUrl === 'string' ? settings.azureDevOpsUrl.trim() : '';
332
+ return { url, source: url ? 'file' : '' };
333
+ };
334
+ /** Resolves the Azure DevOps project prefix or throws a user-facing error. */
335
+ const azureProjectContext = async () => {
336
+ const effective = await effectiveAzureUrl();
337
+ if (!effective.url) {
338
+ throw new Error('Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.');
339
+ }
340
+ let remoteUrl = '';
341
+ try {
342
+ remoteUrl = (await git.remote(['get-url', 'origin'])).trim();
343
+ }
344
+ catch {
345
+ throw new Error('No "origin" remote is configured for this repository.');
346
+ }
347
+ const prefix = buildAzureProjectPrefix(effective.url, remoteUrl);
348
+ if (!prefix) {
349
+ throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
350
+ }
351
+ return { prefix, remoteUrl };
352
+ };
132
353
  // ==================== Repository ====================
133
354
  app.get('/api/repo', async (_req, resp) => {
134
355
  try {
@@ -208,9 +429,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
208
429
  `--grep=${query}`,
209
430
  '--format=%H',
210
431
  ]);
211
- return resp
212
- .type('application/json')
213
- .send({ hashes: raw.split('\n').map((hash) => hash.trim()).filter(Boolean) });
432
+ return resp.type('application/json').send({
433
+ hashes: raw
434
+ .split('\n')
435
+ .map((hash) => hash.trim())
436
+ .filter(Boolean),
437
+ });
214
438
  }
215
439
  catch (err) {
216
440
  if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
@@ -221,8 +445,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
221
445
  });
222
446
  app.post('/api/commit', async (req, resp) => {
223
447
  try {
224
- const { message, description } = req.body;
225
- await git.commit([message, ...(description ? [description] : [])], { '--allow-empty': null });
448
+ const { message, description } = req.body ?? {};
449
+ await mutate(() => working.commit(message, description));
226
450
  return resp.type('application/json').send({ success: true });
227
451
  }
228
452
  catch (err) {
@@ -272,7 +496,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
272
496
  });
273
497
  app.post('/api/reset-commit', async (req, resp) => {
274
498
  try {
275
- await git.raw(['reset', '--hard', req.body.commit]);
499
+ const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
500
+ await git.raw(['reset', `--${mode}`, req.body.commit]);
276
501
  return resp.type('application/json').send({ success: true });
277
502
  }
278
503
  catch (err) {
@@ -291,10 +516,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
291
516
  });
292
517
  app.post('/api/stage', async (req, resp) => {
293
518
  try {
294
- const { files } = req.body;
295
- if (Array.isArray(files) && files.length > 0) {
296
- await git.add(files);
297
- }
519
+ await mutate(() => working.stage(req.body?.files));
298
520
  return resp.type('application/json').send({ success: true });
299
521
  }
300
522
  catch (err) {
@@ -303,10 +525,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
303
525
  });
304
526
  app.post('/api/unstage', async (req, resp) => {
305
527
  try {
306
- const { files } = req.body;
307
- if (Array.isArray(files) && files.length > 0) {
308
- await git.reset(['HEAD', ...files]);
309
- }
528
+ await mutate(() => working.unstage(req.body?.files));
310
529
  return resp.type('application/json').send({ success: true });
311
530
  }
312
531
  catch (err) {
@@ -472,8 +691,22 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
472
691
  });
473
692
  app.post('/api/stash/save', async (req, resp) => {
474
693
  try {
475
- const { message } = req.body;
476
- await git.stash(['save', message || '']);
694
+ const { message, scope } = req.body ?? {};
695
+ // 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
696
+ // (--keep-index leaves staged changes staged); 'all' → index + working tree.
697
+ // Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
698
+ const args = ['push'];
699
+ if (scope === 'staged') {
700
+ args.push('--staged');
701
+ }
702
+ else {
703
+ if (scope === 'unstaged')
704
+ args.push('--keep-index');
705
+ args.push('-u');
706
+ }
707
+ if (message)
708
+ args.push('-m', message);
709
+ await git.stash(args);
477
710
  return resp.type('application/json').send({ success: true });
478
711
  }
479
712
  catch (err) {
@@ -555,6 +788,19 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
555
788
  return resp.status(400).type('application/json').send({ error: err.message });
556
789
  }
557
790
  });
791
+ app.post('/api/tag/push', async (req, resp) => {
792
+ try {
793
+ const { name, remote } = req.body;
794
+ if (!name) {
795
+ return resp.status(400).type('application/json').send({ error: 'name required' });
796
+ }
797
+ await git.push(remote || 'origin', `refs/tags/${name}`);
798
+ return resp.type('application/json').send({ success: true });
799
+ }
800
+ catch (err) {
801
+ return resp.status(400).type('application/json').send({ error: err.message });
802
+ }
803
+ });
558
804
  // ==================== Remote Operations ====================
559
805
  app.get('/api/fetch', async (_req, resp) => {
560
806
  try {
@@ -605,6 +851,270 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
605
851
  return resp.status(400).type('application/json').send({ error: err.message });
606
852
  }
607
853
  });
854
+ // ==================== Settings ====================
855
+ app.get('/api/settings', async (_req, resp) => {
856
+ try {
857
+ const effective = await effectiveAzureUrl();
858
+ return resp
859
+ .type('application/json')
860
+ .send({ azureDevOpsUrl: effective.url, source: effective.source });
861
+ }
862
+ catch (err) {
863
+ return resp.status(400).type('application/json').send({ error: err.message });
864
+ }
865
+ });
866
+ app.post('/api/settings', async (req, resp) => {
867
+ try {
868
+ const url = String(req.body?.azureDevOpsUrl ?? '').trim();
869
+ if (url && !/^https?:\/\//i.test(url)) {
870
+ return resp
871
+ .status(400)
872
+ .type('application/json')
873
+ .send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
874
+ }
875
+ const settings = await readSettings();
876
+ settings.azureDevOpsUrl = url;
877
+ await writeSettings(settings);
878
+ const effective = await effectiveAzureUrl();
879
+ return resp
880
+ .type('application/json')
881
+ .send({ azureDevOpsUrl: effective.url, source: effective.source });
882
+ }
883
+ catch (err) {
884
+ return resp.status(400).type('application/json').send({ error: err.message });
885
+ }
886
+ });
887
+ // ==================== Azure DevOps Pull Requests ====================
888
+ app.post('/api/azure-devops/pullrequest', async (req, resp) => {
889
+ try {
890
+ const { sourceBranch, targetBranch, title, description, newBranch } = req.body ?? {};
891
+ if (!targetBranch) {
892
+ return resp.status(400).type('application/json').send({ error: 'targetBranch required' });
893
+ }
894
+ const { prefix, remoteUrl } = await azureProjectContext();
895
+ const info = parseAzureRemoteUrl(remoteUrl);
896
+ if (!info) {
897
+ return resp
898
+ .status(400)
899
+ .type('application/json')
900
+ .send({ error: 'Unable to build the Azure DevOps pull request URL.' });
901
+ }
902
+ const prUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
903
+ let source = String(sourceBranch ?? '').trim();
904
+ if (newBranch) {
905
+ // Create a remote-only branch from the current HEAD; the local
906
+ // repository and the checked-out branch stay untouched.
907
+ const name = `pr/${randomBranchSuffix()}`;
908
+ await git.push(['origin', `HEAD:refs/heads/${name}`]);
909
+ source = name;
910
+ }
911
+ else {
912
+ if (!source) {
913
+ return resp.status(400).type('application/json').send({ error: 'sourceBranch required' });
914
+ }
915
+ if (source.startsWith('origin/')) {
916
+ // Remote-tracking branch: the PR source is the branch name on the remote.
917
+ source = source.slice('origin/'.length);
918
+ }
919
+ else {
920
+ // Publish the branch first when the remote does not have it yet.
921
+ // The remote-tracking ref is a local check; a redundant push is a
922
+ // harmless no-op ("Everything up-to-date").
923
+ // rev-parse --verify --quiet does not fail reliably through
924
+ // simple-git, so list the tracking refs instead.
925
+ const trackingRefs = await git.raw([
926
+ 'for-each-ref',
927
+ 'refs/remotes/origin',
928
+ '--format=%(refname)',
929
+ ]);
930
+ const published = trackingRefs
931
+ .split('\n')
932
+ .some((line) => line.trim() === `refs/remotes/origin/${source}`);
933
+ if (!published) {
934
+ await git.push(['origin', source]);
935
+ }
936
+ }
937
+ }
938
+ const payload = {
939
+ sourceRefName: `refs/heads/${source}`,
940
+ targetRefName: `refs/heads/${targetBranch}`,
941
+ };
942
+ const trimmedTitle = String(title ?? '').trim();
943
+ const trimmedDescription = String(description ?? '').trim();
944
+ if (trimmedTitle) {
945
+ payload.title = trimmedTitle;
946
+ }
947
+ if (trimmedDescription) {
948
+ payload.description = trimmedDescription;
949
+ }
950
+ const reviewerList = Array.isArray(req.body?.reviewers) ? req.body.reviewers : [];
951
+ const payloadReviewers = reviewerList
952
+ .map((entry) => ({ id: String(entry?.id ?? ''), isRequired: !!entry?.required }))
953
+ .filter((entry) => entry.id);
954
+ if (payloadReviewers.length) {
955
+ payload.reviewers = payloadReviewers;
956
+ }
957
+ const workItemList = Array.isArray(req.body?.workItems) ? req.body.workItems : [];
958
+ const payloadWorkItems = workItemList
959
+ .map((id) => Number(id))
960
+ .filter((id) => Number.isInteger(id) && id > 0);
961
+ if (payloadWorkItems.length) {
962
+ payload.workItems = payloadWorkItems.map((id) => ({ id }));
963
+ }
964
+ const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
965
+ if (result.status < 200 || result.status >= 300) {
966
+ return resp
967
+ .status(400)
968
+ .type('application/json')
969
+ .send({
970
+ error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
971
+ });
972
+ }
973
+ const created = JSON.parse(result.body);
974
+ // Tags (labels) are a separate resource; add them after the PR exists.
975
+ const labelNames = (Array.isArray(req.body?.labels) ? req.body.labels : [])
976
+ .map((name) => String(name ?? '').trim())
977
+ .filter(Boolean);
978
+ const warnings = [];
979
+ if (labelNames.length && created.pullRequestId) {
980
+ // Built from the prefix: prUrl already carries the api-version query.
981
+ const labelsUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}` +
982
+ `/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
983
+ for (const name of labelNames) {
984
+ try {
985
+ const labelResult = await azureRequest('POST', labelsUrl, JSON.stringify({ name }));
986
+ if (labelResult.status < 200 || labelResult.status >= 300) {
987
+ warnings.push(`Could not add tag "${name}" (HTTP ${labelResult.status}).`);
988
+ }
989
+ }
990
+ catch (err) {
991
+ warnings.push(`Could not add tag "${name}": ${err.message}`);
992
+ }
993
+ }
994
+ }
995
+ return resp.type('application/json').send({
996
+ id: created.pullRequestId,
997
+ url: created._links?.web?.href ?? '',
998
+ branch: source,
999
+ ...(warnings.length ? { warnings } : {}),
1000
+ });
1001
+ }
1002
+ catch (err) {
1003
+ return resp.status(400).type('application/json').send({ error: err.message });
1004
+ }
1005
+ });
1006
+ // ==================== 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+).
1009
+ app.get('/api/azure-devops/reviewers', async (req, resp) => {
1010
+ try {
1011
+ const query = String((req.query ?? {}).query ?? '').trim();
1012
+ if (!query) {
1013
+ return resp.type('application/json').send({ reviewers: [] });
1014
+ }
1015
+ 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)}`;
1019
+ const result = await azureRequest('GET', url, '');
1020
+ if (result.status < 200 || result.status >= 300) {
1021
+ return resp
1022
+ .status(400)
1023
+ .type('application/json')
1024
+ .send({
1025
+ error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
1026
+ });
1027
+ }
1028
+ const data = JSON.parse(result.body);
1029
+ const list = Array.isArray(data) ? data : (data.results ?? data.identities ?? []);
1030
+ const reviewers = list
1031
+ .map((entry) => ({
1032
+ id: String(entry?.id ?? entry?.identity?.id ?? ''),
1033
+ label: String(entry?.label ?? entry?.displayName ?? entry?.name ?? ''),
1034
+ description: entry?.description ? String(entry.description) : undefined,
1035
+ }))
1036
+ .filter((entry) => entry.id && entry.label);
1037
+ return resp.type('application/json').send({ reviewers });
1038
+ }
1039
+ catch (err) {
1040
+ return resp.status(400).type('application/json').send({ error: err.message });
1041
+ }
1042
+ });
1043
+ // Work item search by id or title: a WIQL query returns ids, a batch read
1044
+ // then fetches title/state for the first matches.
1045
+ app.get('/api/azure-devops/workitems', async (req, resp) => {
1046
+ try {
1047
+ const query = String((req.query ?? {}).query ?? '').trim();
1048
+ if (!query) {
1049
+ return resp.type('application/json').send({ workItems: [] });
1050
+ }
1051
+ const { prefix } = await azureProjectContext();
1052
+ const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
1053
+ if (/^\d+$/.test(query)) {
1054
+ clauses.push(`[System.Id] = '${query}'`);
1055
+ }
1056
+ const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
1057
+ `WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
1058
+ 'ORDER BY [System.ChangedDate] DESC';
1059
+ const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1060
+ if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
1061
+ return resp
1062
+ .status(400)
1063
+ .type('application/json')
1064
+ .send({
1065
+ error: azureErrorMessage(wiqlResult) || `Azure DevOps returned HTTP ${wiqlResult.status}.`,
1066
+ });
1067
+ }
1068
+ const ids = (JSON.parse(wiqlResult.body).workItems ?? [])
1069
+ .map((entry) => Number(entry?.id))
1070
+ .filter((id) => Number.isInteger(id) && id > 0)
1071
+ .slice(0, 20);
1072
+ if (!ids.length) {
1073
+ return resp.type('application/json').send({ workItems: [] });
1074
+ }
1075
+ const batchResult = await azureRequest('GET', `${prefix}/_apis/wit/workitems?ids=${ids.join(',')}` +
1076
+ `&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, '');
1077
+ if (batchResult.status < 200 || batchResult.status >= 300) {
1078
+ return resp
1079
+ .status(400)
1080
+ .type('application/json')
1081
+ .send({
1082
+ error: azureErrorMessage(batchResult) || `Azure DevOps returned HTTP ${batchResult.status}.`,
1083
+ });
1084
+ }
1085
+ const workItems = (JSON.parse(batchResult.body).value ?? []).map((item) => ({
1086
+ id: Number(item?.id),
1087
+ title: String(item?.fields?.['System.Title'] ?? ''),
1088
+ state: String(item?.fields?.['System.State'] ?? ''),
1089
+ }));
1090
+ return resp.type('application/json').send({ workItems });
1091
+ }
1092
+ catch (err) {
1093
+ return resp.status(400).type('application/json').send({ error: err.message });
1094
+ }
1095
+ });
1096
+ // Work item tag names for the tag autocomplete.
1097
+ app.get('/api/azure-devops/tags', async (_req, resp) => {
1098
+ 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
+ });
1108
+ }
1109
+ const tags = (JSON.parse(result.body).value ?? [])
1110
+ .map((tag) => String(tag?.name ?? '').trim())
1111
+ .filter(Boolean);
1112
+ return resp.type('application/json').send({ tags });
1113
+ }
1114
+ catch (err) {
1115
+ return resp.status(400).type('application/json').send({ error: err.message });
1116
+ }
1117
+ });
608
1118
  // ==================== Commit Detail ====================
609
1119
  app.post('/api/commit/detail', async (req, resp) => {
610
1120
  try {
@@ -620,7 +1130,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
620
1130
  '--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P',
621
1131
  hash,
622
1132
  ]);
623
- const [fullHash, date, message, body, authorName, authorEmail, parents] = raw.trimEnd().split('\u0000');
1133
+ const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
1134
+ .trimEnd()
1135
+ .split('\u0000');
624
1136
  return resp.type('application/json').send({
625
1137
  hash: fullHash,
626
1138
  date,
@@ -664,57 +1176,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
664
1176
  // ==================== Working Changes ====================
665
1177
  app.get('/api/working-changes', async (_req, resp) => {
666
1178
  try {
667
- const root = await repoRoot();
668
- // Combined diff of the working tree + index against HEAD.
669
- const rawDiff = await git.raw(['diff', 'HEAD', '--no-color', '--find-renames']);
670
- const files = parseUnifiedDiff(rawDiff);
671
- // Untracked files are not part of `git diff` — add them as new files.
672
- const status = await git.status();
673
- for (const file of status.not_added ?? []) {
674
- let isBinary = false;
675
- let content = '';
676
- try {
677
- const buffer = await readFile(join(root, file));
678
- if (buffer.includes(0)) {
679
- isBinary = true;
680
- }
681
- else {
682
- content = buffer.toString('utf8');
683
- }
684
- }
685
- catch {
686
- content = '';
687
- }
688
- if (isBinary) {
689
- files.push({
690
- path: file,
691
- oldPath: '',
692
- status: 'binary',
693
- lines: [],
694
- additions: 0,
695
- deletions: 0,
696
- });
697
- }
698
- else {
699
- const lines = content.length > 0 ? content.split('\n') : [];
700
- files.push({
701
- path: file,
702
- oldPath: '',
703
- status: 'added',
704
- lines: lines.map((text, index) => ({ type: 'add', newLine: index + 1, text })),
705
- additions: lines.length,
706
- deletions: 0,
707
- });
708
- }
709
- }
710
- const stagedRaw = await git.raw(['diff', '--name-only', '--cached']);
711
- const unstagedRaw = await git.raw(['diff', '--name-only']);
712
- return resp.type('application/json').send({
713
- files,
714
- staged: stagedRaw.split('\n').filter(Boolean),
715
- unstaged: unstagedRaw.split('\n').filter(Boolean),
716
- untracked: status.not_added ?? [],
717
- });
1179
+ await mutationQueue;
1180
+ return resp.type('application/json').send(await working.snapshot());
718
1181
  }
719
1182
  catch (err) {
720
1183
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -750,7 +1213,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
750
1213
  return resp.type('application/json').send({ content: '' });
751
1214
  }
752
1215
  try {
753
- const content = await git.raw(['show', `${ref}:${path}`]);
1216
+ const content = await git.raw(['show', ref === 'INDEX' ? `:${path}` : `${ref}:${path}`]);
754
1217
  if (content.includes('\u0000')) {
755
1218
  return resp.type('application/json').send({ binary: true, content: '' });
756
1219
  }
@@ -768,7 +1231,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
768
1231
  // ==================== Discard ====================
769
1232
  app.post('/api/discard', async (req, resp) => {
770
1233
  try {
771
- const { files } = req.body;
1234
+ const { files, mode } = req.body;
772
1235
  if (!Array.isArray(files) || files.length === 0) {
773
1236
  return resp.status(400).type('application/json').send({ error: 'files required' });
774
1237
  }
@@ -778,7 +1241,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
778
1241
  const tracked = files.filter((file) => !untracked.has(file));
779
1242
  const removed = files.filter((file) => untracked.has(file));
780
1243
  if (tracked.length > 0) {
781
- await git.raw(['checkout', 'HEAD', '--', ...tracked]);
1244
+ if (mode === 'unstaged') {
1245
+ // Restore from the index so staged changes survive; unmerged (conflicted)
1246
+ // paths cannot be restored from the index and fall back to HEAD.
1247
+ const conflicted = new Set(status.conflicted ?? []);
1248
+ const restorable = tracked.filter((file) => !conflicted.has(file));
1249
+ const unmerged = tracked.filter((file) => conflicted.has(file));
1250
+ if (restorable.length > 0)
1251
+ await git.raw(['checkout', '--', ...restorable]);
1252
+ if (unmerged.length > 0)
1253
+ await git.raw(['checkout', 'HEAD', '--', ...unmerged]);
1254
+ }
1255
+ else {
1256
+ await git.raw(['checkout', 'HEAD', '--', ...tracked]);
1257
+ }
782
1258
  }
783
1259
  for (const file of removed) {
784
1260
  await rm(join(root, file), { force: true, recursive: true });