guito 1.0.3 → 1.0.5

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.
@@ -235,7 +235,7 @@ const azureErrorMessage = (result) => {
235
235
  return result.body;
236
236
  }
237
237
  };
238
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, allowMerge, fileListView, refListView, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
238
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, allowMerge, fileListView, refListView, sidePanelSectionsExpanded, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
239
239
  // Initialize server
240
240
  const app = fastify({
241
241
  logger: false,
@@ -401,6 +401,84 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
401
401
  mutationQueue = result.catch(() => { });
402
402
  return result;
403
403
  };
404
+ // ==================== Mutation option helpers ====================
405
+ // Shared guards for the option fields the Git operation dialogs send.
406
+ // Every value is allowlisted before it reaches a Git argument array.
407
+ /** Reads a trimmed string body field; non-strings become ''. */
408
+ const strField = (value) => (typeof value === 'string' ? value.trim() : '');
409
+ /** Reads a boolean body field; only an explicit true enables the option. */
410
+ const boolField = (value) => value === true;
411
+ /** Sends `{ success: true }`, adding warnings for partially-failed composites. */
412
+ const mutationResult = (resp, warnings) => resp.type('application/json').send({
413
+ success: true,
414
+ ...(warnings.length ? { warnings } : {}),
415
+ });
416
+ /**
417
+ * Resolves the remote a remote-capable action should use: an explicit name
418
+ * (which must be configured), else "origin", else the first remote.
419
+ */
420
+ const resolveRemote = async (requested) => {
421
+ const names = (await git.raw(['remote']))
422
+ .split(/\r?\n/)
423
+ .map((name) => name.trim())
424
+ .filter(Boolean);
425
+ if (names.length === 0)
426
+ throw new Error('No remotes are configured for this repository.');
427
+ if (requested) {
428
+ if (!names.includes(requested))
429
+ throw new Error(`Remote "${requested}" is not configured.`);
430
+ return requested;
431
+ }
432
+ return names.includes('origin') ? 'origin' : names[0];
433
+ };
434
+ /** Verifies a branch name Git would accept, before it is used as an argument. */
435
+ const verifyBranchName = async (name) => {
436
+ await git.raw(['check-ref-format', '--branch', name]);
437
+ };
438
+ /** Verifies a tag name Git would accept (rejects "", "..", "~", "^", ":"...). */
439
+ const verifyTagName = async (name) => {
440
+ await git.raw(['check-ref-format', `refs/tags/${name}`]);
441
+ };
442
+ /** Parent hashes of a commit, used for merge-commit mainline validation. */
443
+ const commitParents = async (commit) => {
444
+ const raw = await git.raw(['rev-list', '--parents', '-n', '1', commit]);
445
+ return raw.trim().split(/\s+/).slice(1).filter(Boolean);
446
+ };
447
+ /** Validates a mainline parent selection for a merge commit. */
448
+ const mainlineArg = (mainline, parents) => {
449
+ const parsed = Number(mainline);
450
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > parents.length) {
451
+ throw new Error(`Choose a mainline parent between 1 and ${parents.length}.`);
452
+ }
453
+ return String(parsed);
454
+ };
455
+ /** Rejects tracked changes that would block a history-rewriting operation. */
456
+ const requireCleanWorktree = async () => {
457
+ const status = await git.raw(['status', '--porcelain']);
458
+ const dirty = status
459
+ .split(/\r?\n/)
460
+ .filter(Boolean)
461
+ .filter((line) => !line.startsWith('??'));
462
+ if (dirty.length > 0) {
463
+ throw new Error('The working tree has uncommitted changes. Commit or stash them first.');
464
+ }
465
+ };
466
+ /** Requires HEAD to sit on a branch (not detached), returning its name. */
467
+ const currentBranch = async () => {
468
+ // simple-git may swallow the non-zero exit of a detached symbolic-ref, so
469
+ // parse the output instead of relying on the rejection.
470
+ let head = '';
471
+ try {
472
+ head = await git.raw(['symbolic-ref', '--quiet', 'HEAD']);
473
+ }
474
+ catch {
475
+ head = '';
476
+ }
477
+ const branch = head.trim().replace(/^refs\/heads\//, '');
478
+ if (!branch)
479
+ throw new Error('HEAD is detached. Check out a branch first.');
480
+ return branch;
481
+ };
404
482
  const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
405
483
  // ==================== Azure DevOps settings ====================
406
484
  // Settings live in the repository's git directory so they are per-repo and
@@ -583,6 +661,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
583
661
  const fileShowRemoteBranches = typeof file.showRemoteBranches === 'boolean' ? file.showRemoteBranches : undefined;
584
662
  const fileFileListView = file.fileListView === 'tree' || file.fileListView === 'flat' ? file.fileListView : undefined;
585
663
  const fileRefListView = file.refListView === 'tree' || file.refListView === 'flat' ? file.refListView : undefined;
664
+ const fileSidePanelSectionsExpanded = typeof file.sidePanelSectionsExpanded === 'boolean'
665
+ ? file.sidePanelSectionsExpanded
666
+ : undefined;
586
667
  const fileSearchMode = file.searchMode === 'filter' || file.searchMode === 'navigate' ? file.searchMode : undefined;
587
668
  const fileAllowMerge = typeof file.allowMerge === 'boolean' ? file.allowMerge : undefined;
588
669
  const fileSearchCaseSensitive = typeof file.searchCaseSensitive === 'boolean' ? file.searchCaseSensitive : undefined;
@@ -632,6 +713,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
632
713
  : (fileShowRemoteBranches ?? true),
633
714
  fileListView: fileListView ?? fileFileListView ?? 'flat',
634
715
  refListView: refListView ?? fileRefListView ?? 'flat',
716
+ sidePanelSectionsExpanded: typeof sidePanelSectionsExpanded === 'boolean'
717
+ ? sidePanelSectionsExpanded
718
+ : (fileSidePanelSectionsExpanded ?? true),
635
719
  searchMode: searchMode ?? fileSearchMode ?? 'navigate',
636
720
  searchCaseSensitive: typeof searchCaseSensitive === 'boolean'
637
721
  ? searchCaseSensitive
@@ -788,9 +872,25 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
788
872
  });
789
873
  app.post('/api/revert', async (req, resp) => {
790
874
  try {
791
- const { commit } = req.body;
792
- await git.revert(commit);
793
- return resp.type('application/json').send({ success: true });
875
+ const hash = strField(req.body?.commit);
876
+ if (!hash)
877
+ throw new Error('A commit is required.');
878
+ const noCommit = boolField(req.body?.noCommit);
879
+ const signoff = boolField(req.body?.signoff);
880
+ await mutate(async () => {
881
+ const parents = await commitParents(hash);
882
+ const args = ['revert'];
883
+ // Merge commits must pick the parent the changes are reverted against.
884
+ if (parents.length > 1)
885
+ args.push('--mainline', mainlineArg(req.body?.mainline, parents));
886
+ if (noCommit)
887
+ args.push('--no-commit');
888
+ if (signoff)
889
+ args.push('--signoff');
890
+ args.push(hash);
891
+ await git.raw(args);
892
+ });
893
+ return mutationResult(resp, []);
794
894
  }
795
895
  catch (err) {
796
896
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -798,8 +898,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
798
898
  });
799
899
  app.post('/api/cherry-pick', async (req, resp) => {
800
900
  try {
801
- await git.raw(['cherry-pick', req.body.commit]);
802
- return resp.type('application/json').send({ success: true });
901
+ const hash = strField(req.body?.commit);
902
+ if (!hash)
903
+ throw new Error('A commit is required.');
904
+ const noCommit = boolField(req.body?.noCommit);
905
+ const recordSource = boolField(req.body?.recordSource);
906
+ const signoff = boolField(req.body?.signoff);
907
+ await mutate(async () => {
908
+ const parents = await commitParents(hash);
909
+ const args = ['cherry-pick'];
910
+ // Merge commits must pick the parent the changes are picked against.
911
+ if (parents.length > 1)
912
+ args.push('--mainline', mainlineArg(req.body?.mainline, parents));
913
+ if (noCommit)
914
+ args.push('--no-commit');
915
+ if (recordSource)
916
+ args.push('-x');
917
+ if (signoff)
918
+ args.push('--signoff');
919
+ args.push(hash);
920
+ await git.raw(args);
921
+ });
922
+ return mutationResult(resp, []);
803
923
  }
804
924
  catch (err) {
805
925
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -807,8 +927,32 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
807
927
  });
808
928
  app.post('/api/commit/drop', async (req, resp) => {
809
929
  try {
810
- await git.raw(['reset', '--hard', `${req.body.commit}^`]);
811
- return resp.type('application/json').send({ success: true });
930
+ const hash = strField(req.body?.commit);
931
+ if (!hash)
932
+ throw new Error('A commit is required.');
933
+ await mutate(async () => {
934
+ // Dropping rewrites history: only a clean, branch-bound working tree
935
+ // can safely replay the descendants of the selected commit.
936
+ await currentBranch();
937
+ await requireCleanWorktree();
938
+ const parents = await commitParents(hash);
939
+ if (parents.length === 0)
940
+ throw new Error('The root commit cannot be dropped.');
941
+ if (parents.length > 1)
942
+ throw new Error('Merge commits cannot be dropped.');
943
+ // The commit must be part of the current branch's history: when it is
944
+ // an ancestor of HEAD, it is also the merge base of the two.
945
+ const base = (await git.raw(['merge-base', hash, 'HEAD'])).trim();
946
+ if (base !== hash) {
947
+ throw new Error('The commit is not reachable from the current branch.');
948
+ }
949
+ // Replay every descendant onto the dropped commit's parent, removing
950
+ // only the selected commit (a hard reset would lose the descendants).
951
+ // Rebase by branch name: rebasing HEAD detaches it instead of moving
952
+ // the branch ref.
953
+ await git.raw(['rebase', '--onto', `${hash}~1`, hash, await currentBranch()]);
954
+ });
955
+ return mutationResult(resp, []);
812
956
  }
813
957
  catch (err) {
814
958
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -816,9 +960,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
816
960
  });
817
961
  app.post('/api/reset-commit', async (req, resp) => {
818
962
  try {
963
+ const hash = strField(req.body?.commit);
964
+ if (!hash)
965
+ throw new Error('A commit is required.');
819
966
  const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
820
- await git.raw(['reset', `--${mode}`, req.body.commit]);
821
- return resp.type('application/json').send({ success: true });
967
+ await mutate(() => git.raw(['reset', `--${mode}`, hash]));
968
+ return mutationResult(resp, []);
822
969
  }
823
970
  catch (err) {
824
971
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -899,9 +1046,36 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
899
1046
  });
900
1047
  app.post('/api/branch/create', async (req, resp) => {
901
1048
  try {
902
- const { name, startPoint } = req.body;
903
- await git.branch([...(startPoint ? [name, startPoint] : [name])]);
904
- return resp.type('application/json').send({ success: true });
1049
+ const name = strField(req.body?.name);
1050
+ if (!name)
1051
+ throw new Error('A branch name is required.');
1052
+ await verifyBranchName(name);
1053
+ const startPoint = strField(req.body?.startPoint);
1054
+ const checkout = boolField(req.body?.checkout);
1055
+ const warnings = [];
1056
+ await mutate(async () => {
1057
+ // Resolve the remote up front so a bad configuration fails before any
1058
+ // local branch is created.
1059
+ const publishRemote = boolField(req.body?.publish)
1060
+ ? await resolveRemote(strField(req.body?.remote))
1061
+ : '';
1062
+ if (checkout) {
1063
+ await git.raw(['checkout', '-b', name, ...(startPoint ? [startPoint] : [])]);
1064
+ }
1065
+ else {
1066
+ await git.branch([...(startPoint ? [name, startPoint] : [name])]);
1067
+ }
1068
+ // Publishing is a follow-up step: keep the created branch when it fails.
1069
+ if (publishRemote) {
1070
+ try {
1071
+ await git.push(['-u', publishRemote, name]);
1072
+ }
1073
+ catch (err) {
1074
+ warnings.push(`Branch "${name}" was created but could not be published to "${publishRemote}": ${err.message}`);
1075
+ }
1076
+ }
1077
+ });
1078
+ return mutationResult(resp, warnings);
905
1079
  }
906
1080
  catch (err) {
907
1081
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -909,9 +1083,29 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
909
1083
  });
910
1084
  app.post('/api/branch/delete', async (req, resp) => {
911
1085
  try {
912
- const { name, force } = req.body;
913
- await git.deleteLocalBranch(name, force);
914
- return resp.type('application/json').send({ success: true });
1086
+ const name = strField(req.body?.name);
1087
+ if (!name)
1088
+ throw new Error('A branch name is required.');
1089
+ const force = boolField(req.body?.force);
1090
+ const warnings = [];
1091
+ await mutate(async () => {
1092
+ // Resolve the remote up front so a bad configuration fails before the
1093
+ // local branch is deleted.
1094
+ const deleteRemote = boolField(req.body?.deleteRemote)
1095
+ ? await resolveRemote(strField(req.body?.remote))
1096
+ : '';
1097
+ await git.deleteLocalBranch(name, force);
1098
+ // Deleting on the remote is a follow-up: keep the local deletion when it fails.
1099
+ if (deleteRemote) {
1100
+ try {
1101
+ await git.push([deleteRemote, '--delete', `refs/heads/${name}`]);
1102
+ }
1103
+ catch (err) {
1104
+ warnings.push(`Branch "${name}" was deleted locally but could not be deleted on "${deleteRemote}": ${err.message}`);
1105
+ }
1106
+ }
1107
+ });
1108
+ return mutationResult(resp, warnings);
915
1109
  }
916
1110
  catch (err) {
917
1111
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -919,9 +1113,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
919
1113
  });
920
1114
  app.post('/api/branch/delete-remote', async (req, resp) => {
921
1115
  try {
922
- const { remote, branch } = req.body;
923
- await git.push([remote, '--delete', branch]);
924
- return resp.type('application/json').send({ success: true });
1116
+ const remote = strField(req.body?.remote);
1117
+ const branch = strField(req.body?.branch);
1118
+ if (!remote || !branch)
1119
+ throw new Error('A remote and branch are required.');
1120
+ const resolved = await resolveRemote(remote);
1121
+ await mutate(() => git.push([resolved, '--delete', `refs/heads/${branch}`]));
1122
+ return mutationResult(resp, []);
925
1123
  }
926
1124
  catch (err) {
927
1125
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -929,9 +1127,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
929
1127
  });
930
1128
  app.post('/api/branch/rename', async (req, resp) => {
931
1129
  try {
932
- const { oldName, newName } = req.body;
933
- await git.branch(['-m', oldName, newName]);
934
- return resp.type('application/json').send({ success: true });
1130
+ const oldName = strField(req.body?.oldName);
1131
+ const newName = strField(req.body?.newName);
1132
+ if (!oldName || !newName)
1133
+ throw new Error('The current and new branch names are required.');
1134
+ await verifyBranchName(newName);
1135
+ const publish = boolField(req.body?.publish);
1136
+ const deleteOld = boolField(req.body?.deleteRemoteOld);
1137
+ const warnings = [];
1138
+ await mutate(async () => {
1139
+ // Resolve the remote up front so a bad configuration fails before the
1140
+ // branch is renamed.
1141
+ const needsRemote = publish || deleteOld;
1142
+ const remote = needsRemote ? await resolveRemote(strField(req.body?.remote)) : '';
1143
+ await git.branch(['-m', oldName, newName]);
1144
+ // Publishing/deleting the old ref are follow-ups: keep the rename when they fail.
1145
+ if (remote) {
1146
+ if (publish) {
1147
+ try {
1148
+ await git.push(['-u', remote, newName]);
1149
+ }
1150
+ catch (err) {
1151
+ warnings.push(`Branch "${newName}" was renamed but could not be published to "${remote}": ${err.message}`);
1152
+ }
1153
+ }
1154
+ if (deleteOld) {
1155
+ try {
1156
+ await git.push([remote, '--delete', `refs/heads/${oldName}`]);
1157
+ }
1158
+ catch (err) {
1159
+ warnings.push(`The old branch "${oldName}" was renamed locally but could not be deleted on "${remote}": ${err.message}`);
1160
+ }
1161
+ }
1162
+ }
1163
+ });
1164
+ return mutationResult(resp, warnings);
935
1165
  }
936
1166
  catch (err) {
937
1167
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1033,9 +1263,44 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1033
1263
  });
1034
1264
  app.post('/api/checkout', async (req, resp) => {
1035
1265
  try {
1036
- const { ref } = req.body;
1037
- await git.checkout(ref);
1038
- return resp.type('application/json').send({ success: true });
1266
+ const ref = strField(req.body?.ref);
1267
+ if (!ref)
1268
+ throw new Error('A ref is required.');
1269
+ const newBranch = strField(req.body?.newBranch);
1270
+ const detach = boolField(req.body?.detach);
1271
+ const track = boolField(req.body?.track);
1272
+ const warnings = [];
1273
+ await mutate(async () => {
1274
+ // Resolve the remote up front so a bad configuration fails before the
1275
+ // checkout happens.
1276
+ const publishRemote = newBranch && boolField(req.body?.publish)
1277
+ ? await resolveRemote(strField(req.body?.remote))
1278
+ : '';
1279
+ if (newBranch) {
1280
+ await verifyBranchName(newBranch);
1281
+ // --track sets the upstream when checking out a remote-tracking ref.
1282
+ await git.raw([
1283
+ 'checkout',
1284
+ '-b',
1285
+ newBranch,
1286
+ ...(track ? ['--track'] : []),
1287
+ ref,
1288
+ ]);
1289
+ }
1290
+ else {
1291
+ await git.raw(['checkout', ...(detach ? ['--detach'] : []), ref]);
1292
+ }
1293
+ // Publishing the new branch is a follow-up: keep the checkout when it fails.
1294
+ if (publishRemote) {
1295
+ try {
1296
+ await git.push(['-u', publishRemote, newBranch]);
1297
+ }
1298
+ catch (err) {
1299
+ warnings.push(`Branch "${newBranch}" was checked out but could not be published to "${publishRemote}": ${err.message}`);
1300
+ }
1301
+ }
1302
+ });
1303
+ return mutationResult(resp, warnings);
1039
1304
  }
1040
1305
  catch (err) {
1041
1306
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1044,9 +1309,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1044
1309
  // ==================== Merge & Rebase ====================
1045
1310
  app.post('/api/merge', async (req, resp) => {
1046
1311
  try {
1047
- const { branch } = req.body;
1048
- await git.merge([branch]);
1049
- return resp.type('application/json').send({ success: true });
1312
+ const branch = strField(req.body?.branch);
1313
+ if (!branch)
1314
+ throw new Error('A branch or commit is required.');
1315
+ const mode = ['default', 'no-ff', 'ff-only', 'squash'].includes(req.body?.mode)
1316
+ ? req.body.mode
1317
+ : 'default';
1318
+ // --no-commit only applies to modes that would create a commit.
1319
+ const noCommit = boolField(req.body?.noCommit) && (mode === 'default' || mode === 'no-ff');
1320
+ const args = ['merge'];
1321
+ if (mode === 'no-ff')
1322
+ args.push('--no-ff');
1323
+ if (mode === 'ff-only')
1324
+ args.push('--ff-only');
1325
+ if (mode === 'squash')
1326
+ args.push('--squash');
1327
+ if (noCommit)
1328
+ args.push('--no-commit');
1329
+ if (boolField(req.body?.autostash))
1330
+ args.push('--autostash');
1331
+ args.push(branch);
1332
+ await mutate(() => git.raw(args));
1333
+ return mutationResult(resp, []);
1050
1334
  }
1051
1335
  catch (err) {
1052
1336
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1074,9 +1358,18 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1074
1358
  });
1075
1359
  app.post('/api/rebase', async (req, resp) => {
1076
1360
  try {
1077
- const { branch } = req.body;
1078
- await git.rebase([branch]);
1079
- return resp.type('application/json').send({ success: true });
1361
+ const branch = strField(req.body?.branch);
1362
+ if (!branch)
1363
+ throw new Error('A branch or commit is required.');
1364
+ await mutate(() => git.raw([
1365
+ 'rebase',
1366
+ ...(boolField(req.body?.autostash) ? ['--autostash'] : []),
1367
+ // --preserve-merges was removed in Git 2.35+; --rebase-merges is the
1368
+ // supported way to keep merge commits while rebasing.
1369
+ ...(boolField(req.body?.preserveMerges) ? ['--rebase-merges'] : []),
1370
+ branch,
1371
+ ]));
1372
+ return mutationResult(resp, []);
1080
1373
  }
1081
1374
  catch (err) {
1082
1375
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1125,23 +1418,24 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1125
1418
  });
1126
1419
  app.post('/api/stash/save', async (req, resp) => {
1127
1420
  try {
1128
- const { message, scope } = req.body ?? {};
1421
+ const message = strField(req.body?.message);
1422
+ const scope = ['staged', 'unstaged', 'all'].includes(req.body?.scope) ? req.body.scope : 'all';
1129
1423
  // 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
1130
1424
  // (--keep-index leaves staged changes staged); 'all' → index + working tree.
1131
- // Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
1425
+ // Untracked files ride along unless includeUntracked opts out (the legacy
1426
+ // callers kept them for 'all'/'unstaged', which stay the defaults).
1427
+ const includeUntracked = typeof req.body?.includeUntracked === 'boolean' ? req.body.includeUntracked : scope !== 'staged';
1132
1428
  const args = ['push'];
1133
- if (scope === 'staged') {
1429
+ if (scope === 'staged')
1134
1430
  args.push('--staged');
1135
- }
1136
- else {
1137
- if (scope === 'unstaged')
1138
- args.push('--keep-index');
1431
+ if (scope === 'unstaged')
1432
+ args.push('--keep-index');
1433
+ if (includeUntracked)
1139
1434
  args.push('-u');
1140
- }
1141
1435
  if (message)
1142
1436
  args.push('-m', message);
1143
- await git.stash(args);
1144
- return resp.type('application/json').send({ success: true });
1437
+ await mutate(() => git.stash(args));
1438
+ return mutationResult(resp, []);
1145
1439
  }
1146
1440
  catch (err) {
1147
1441
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1149,9 +1443,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1149
1443
  });
1150
1444
  app.post('/api/stash/apply', async (req, resp) => {
1151
1445
  try {
1152
- const { index } = req.body;
1153
- await git.stash(['apply', `stash@{${index}}`]);
1154
- return resp.type('application/json').send({ success: true });
1446
+ const index = Number(req.body?.index);
1447
+ if (!Number.isInteger(index) || index < 0)
1448
+ throw new Error('A stash index is required.');
1449
+ // --index restores the staged/unstaged split recorded by the stash.
1450
+ const args = ['apply', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
1451
+ await mutate(() => git.stash(args));
1452
+ return mutationResult(resp, []);
1155
1453
  }
1156
1454
  catch (err) {
1157
1455
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1159,9 +1457,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1159
1457
  });
1160
1458
  app.post('/api/stash/pop', async (req, resp) => {
1161
1459
  try {
1162
- const { index } = req.body;
1163
- await git.stash(['pop', `stash@{${index}}`]);
1164
- return resp.type('application/json').send({ success: true });
1460
+ const index = Number(req.body?.index);
1461
+ if (!Number.isInteger(index) || index < 0)
1462
+ throw new Error('A stash index is required.');
1463
+ const args = ['pop', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
1464
+ await mutate(() => git.stash(args));
1465
+ return mutationResult(resp, []);
1165
1466
  }
1166
1467
  catch (err) {
1167
1468
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1170,8 +1471,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1170
1471
  app.post('/api/stash/drop', async (req, resp) => {
1171
1472
  try {
1172
1473
  const { index } = req.body;
1173
- await git.stash(['drop', `stash@{${index}}`]);
1174
- return resp.type('application/json').send({ success: true });
1474
+ await mutate(() => git.stash(['drop', `stash@{${index}}`]));
1475
+ return mutationResult(resp, []);
1175
1476
  }
1176
1477
  catch (err) {
1177
1478
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1211,14 +1512,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1211
1512
  });
1212
1513
  app.post('/api/tag/create', async (req, resp) => {
1213
1514
  try {
1214
- const { name, message, commit } = req.body;
1215
- if (message) {
1216
- await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1217
- }
1218
- else {
1219
- await git.tag([name, ...(commit ? [commit] : [])]);
1220
- }
1221
- return resp.type('application/json').send({ success: true });
1515
+ const name = strField(req.body?.name);
1516
+ if (!name)
1517
+ throw new Error('A tag name is required.');
1518
+ await verifyTagName(name);
1519
+ const message = strField(req.body?.message);
1520
+ // A message always produces an annotated tag (legacy callers relied on it);
1521
+ // the dialog sends annotate explicitly and must then include a message.
1522
+ const annotate = boolField(req.body?.annotate) || !!message;
1523
+ if (annotate && !message)
1524
+ throw new Error('An annotated tag requires a message.');
1525
+ const commit = strField(req.body?.commit);
1526
+ const warnings = [];
1527
+ await mutate(async () => {
1528
+ // Resolve the remote up front so a bad configuration fails before the
1529
+ // tag is created.
1530
+ const pushRemote = boolField(req.body?.push)
1531
+ ? await resolveRemote(strField(req.body?.remote))
1532
+ : '';
1533
+ if (annotate) {
1534
+ await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1535
+ }
1536
+ else {
1537
+ await git.tag([name, ...(commit ? [commit] : [])]);
1538
+ }
1539
+ // Pushing is a follow-up: keep the created tag when it fails.
1540
+ if (pushRemote) {
1541
+ try {
1542
+ await git.push([pushRemote, `refs/tags/${name}`]);
1543
+ }
1544
+ catch (err) {
1545
+ warnings.push(`Tag "${name}" was created but could not be pushed to "${pushRemote}": ${err.message}`);
1546
+ }
1547
+ }
1548
+ });
1549
+ return mutationResult(resp, warnings);
1222
1550
  }
1223
1551
  catch (err) {
1224
1552
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1226,9 +1554,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1226
1554
  });
1227
1555
  app.post('/api/tag/delete', async (req, resp) => {
1228
1556
  try {
1229
- const { name } = req.body;
1230
- await git.tag(['-d', name]);
1231
- return resp.type('application/json').send({ success: true });
1557
+ const name = strField(req.body?.name);
1558
+ if (!name)
1559
+ throw new Error('A tag name is required.');
1560
+ const warnings = [];
1561
+ await mutate(async () => {
1562
+ // Resolve the remote up front so a bad configuration fails before the
1563
+ // local tag is deleted.
1564
+ const deleteRemote = boolField(req.body?.deleteRemote)
1565
+ ? await resolveRemote(strField(req.body?.remote))
1566
+ : '';
1567
+ await git.tag(['-d', name]);
1568
+ // Deleting on the remote is a follow-up: keep the local deletion when it fails.
1569
+ if (deleteRemote) {
1570
+ try {
1571
+ await git.push([deleteRemote, '--delete', `refs/tags/${name}`]);
1572
+ }
1573
+ catch (err) {
1574
+ warnings.push(`Tag "${name}" was deleted locally but could not be deleted on "${deleteRemote}": ${err.message}`);
1575
+ }
1576
+ }
1577
+ });
1578
+ return mutationResult(resp, warnings);
1232
1579
  }
1233
1580
  catch (err) {
1234
1581
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1236,12 +1583,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1236
1583
  });
1237
1584
  app.post('/api/tag/push', async (req, resp) => {
1238
1585
  try {
1239
- const { name, remote } = req.body;
1240
- if (!name) {
1241
- return resp.status(400).type('application/json').send({ error: 'name required' });
1242
- }
1243
- await git.push(remote || 'origin', `refs/tags/${name}`);
1244
- return resp.type('application/json').send({ success: true });
1586
+ const name = strField(req.body?.name);
1587
+ if (!name)
1588
+ throw new Error('A tag name is required.');
1589
+ const remote = await resolveRemote(strField(req.body?.remote));
1590
+ await mutate(() => git.push([remote, ...(boolField(req.body?.force) ? ['-f'] : []), `refs/tags/${name}`]));
1591
+ return mutationResult(resp, []);
1245
1592
  }
1246
1593
  catch (err) {
1247
1594
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1260,9 +1607,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1260
1607
  });
1261
1608
  app.post('/api/pull', async (req, resp) => {
1262
1609
  try {
1263
- const { remote, branch, rebase } = req.body;
1264
- await git.pull(remote || 'origin', branch || undefined, rebase ? { '--rebase': null } : {});
1265
- return resp.type('application/json').send({ success: true });
1610
+ const remote = strField(req.body?.remote);
1611
+ const branch = strField(req.body?.branch);
1612
+ // 'mode' supersedes the legacy 'rebase' boolean (kept for older callers).
1613
+ const mode = ['merge', 'rebase', 'ff-only'].includes(req.body?.mode) ? req.body.mode : 'merge';
1614
+ const rebase = boolField(req.body?.rebase) || mode === 'rebase';
1615
+ const ffOnly = mode === 'ff-only';
1616
+ // An explicit remote must be configured; without one, keep the legacy
1617
+ // 'origin' default so upstream-based pulls keep working.
1618
+ const resolvedRemote = remote ? await resolveRemote(remote) : 'origin';
1619
+ await mutate(() => git.pull(resolvedRemote, branch || undefined, {
1620
+ ...(rebase ? { '--rebase': null } : {}),
1621
+ ...(ffOnly ? { '--ff-only': null } : {}),
1622
+ }));
1623
+ return mutationResult(resp, []);
1266
1624
  }
1267
1625
  catch (err) {
1268
1626
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1448,6 +1806,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1448
1806
  if (body.refListView === 'tree' || body.refListView === 'flat') {
1449
1807
  settings.refListView = body.refListView;
1450
1808
  }
1809
+ if (typeof body.sidePanelSectionsExpanded === 'boolean') {
1810
+ settings.sidePanelSectionsExpanded = body.sidePanelSectionsExpanded;
1811
+ }
1451
1812
  if (body.searchMode === 'filter' || body.searchMode === 'navigate') {
1452
1813
  settings.searchMode = body.searchMode;
1453
1814
  }
@@ -2849,8 +3210,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2849
3210
  // ==================== Working Tree Actions ====================
2850
3211
  app.post('/api/reset', async (_req, resp) => {
2851
3212
  try {
2852
- await git.raw(['reset', '--hard', 'HEAD']);
2853
- return resp.type('application/json').send({ ok: true });
3213
+ await mutate(() => git.raw(['reset', '--hard', 'HEAD']));
3214
+ return mutationResult(resp, []);
2854
3215
  }
2855
3216
  catch (err) {
2856
3217
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2858,8 +3219,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2858
3219
  });
2859
3220
  app.post('/api/clean', async (_req, resp) => {
2860
3221
  try {
2861
- await git.raw(['clean', '-fd']);
2862
- return resp.type('application/json').send({ ok: true });
3222
+ await mutate(() => git.raw(['clean', '-fd']));
3223
+ return mutationResult(resp, []);
2863
3224
  }
2864
3225
  catch (err) {
2865
3226
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2877,6 +3238,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2877
3238
  showGraph = settings.showGraph;
2878
3239
  showStashes = settings.showStashes;
2879
3240
  fileListView = settings.fileListView;
3241
+ sidePanelSectionsExpanded = settings.sidePanelSectionsExpanded;
2880
3242
  searchMode = settings.searchMode;
2881
3243
  searchCaseSensitive = settings.searchCaseSensitive;
2882
3244
  showTags = settings.showTags;