guito 1.0.4 → 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.
@@ -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
@@ -794,9 +872,25 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
794
872
  });
795
873
  app.post('/api/revert', async (req, resp) => {
796
874
  try {
797
- const { commit } = req.body;
798
- await git.revert(commit);
799
- 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, []);
800
894
  }
801
895
  catch (err) {
802
896
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -804,8 +898,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
804
898
  });
805
899
  app.post('/api/cherry-pick', async (req, resp) => {
806
900
  try {
807
- await git.raw(['cherry-pick', req.body.commit]);
808
- 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, []);
809
923
  }
810
924
  catch (err) {
811
925
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -813,8 +927,32 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
813
927
  });
814
928
  app.post('/api/commit/drop', async (req, resp) => {
815
929
  try {
816
- await git.raw(['reset', '--hard', `${req.body.commit}^`]);
817
- 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, []);
818
956
  }
819
957
  catch (err) {
820
958
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -822,9 +960,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
822
960
  });
823
961
  app.post('/api/reset-commit', async (req, resp) => {
824
962
  try {
963
+ const hash = strField(req.body?.commit);
964
+ if (!hash)
965
+ throw new Error('A commit is required.');
825
966
  const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
826
- await git.raw(['reset', `--${mode}`, req.body.commit]);
827
- return resp.type('application/json').send({ success: true });
967
+ await mutate(() => git.raw(['reset', `--${mode}`, hash]));
968
+ return mutationResult(resp, []);
828
969
  }
829
970
  catch (err) {
830
971
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -905,9 +1046,36 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
905
1046
  });
906
1047
  app.post('/api/branch/create', async (req, resp) => {
907
1048
  try {
908
- const { name, startPoint } = req.body;
909
- await git.branch([...(startPoint ? [name, startPoint] : [name])]);
910
- 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);
911
1079
  }
912
1080
  catch (err) {
913
1081
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -915,9 +1083,29 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
915
1083
  });
916
1084
  app.post('/api/branch/delete', async (req, resp) => {
917
1085
  try {
918
- const { name, force } = req.body;
919
- await git.deleteLocalBranch(name, force);
920
- 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);
921
1109
  }
922
1110
  catch (err) {
923
1111
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -925,9 +1113,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
925
1113
  });
926
1114
  app.post('/api/branch/delete-remote', async (req, resp) => {
927
1115
  try {
928
- const { remote, branch } = req.body;
929
- await git.push([remote, '--delete', branch]);
930
- 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, []);
931
1123
  }
932
1124
  catch (err) {
933
1125
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -935,9 +1127,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
935
1127
  });
936
1128
  app.post('/api/branch/rename', async (req, resp) => {
937
1129
  try {
938
- const { oldName, newName } = req.body;
939
- await git.branch(['-m', oldName, newName]);
940
- 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);
941
1165
  }
942
1166
  catch (err) {
943
1167
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1039,9 +1263,44 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1039
1263
  });
1040
1264
  app.post('/api/checkout', async (req, resp) => {
1041
1265
  try {
1042
- const { ref } = req.body;
1043
- await git.checkout(ref);
1044
- 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);
1045
1304
  }
1046
1305
  catch (err) {
1047
1306
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1050,9 +1309,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1050
1309
  // ==================== Merge & Rebase ====================
1051
1310
  app.post('/api/merge', async (req, resp) => {
1052
1311
  try {
1053
- const { branch } = req.body;
1054
- await git.merge([branch]);
1055
- 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, []);
1056
1334
  }
1057
1335
  catch (err) {
1058
1336
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1080,9 +1358,18 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1080
1358
  });
1081
1359
  app.post('/api/rebase', async (req, resp) => {
1082
1360
  try {
1083
- const { branch } = req.body;
1084
- await git.rebase([branch]);
1085
- 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, []);
1086
1373
  }
1087
1374
  catch (err) {
1088
1375
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1131,23 +1418,24 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1131
1418
  });
1132
1419
  app.post('/api/stash/save', async (req, resp) => {
1133
1420
  try {
1134
- 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';
1135
1423
  // 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
1136
1424
  // (--keep-index leaves staged changes staged); 'all' → index + working tree.
1137
- // 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';
1138
1428
  const args = ['push'];
1139
- if (scope === 'staged') {
1429
+ if (scope === 'staged')
1140
1430
  args.push('--staged');
1141
- }
1142
- else {
1143
- if (scope === 'unstaged')
1144
- args.push('--keep-index');
1431
+ if (scope === 'unstaged')
1432
+ args.push('--keep-index');
1433
+ if (includeUntracked)
1145
1434
  args.push('-u');
1146
- }
1147
1435
  if (message)
1148
1436
  args.push('-m', message);
1149
- await git.stash(args);
1150
- return resp.type('application/json').send({ success: true });
1437
+ await mutate(() => git.stash(args));
1438
+ return mutationResult(resp, []);
1151
1439
  }
1152
1440
  catch (err) {
1153
1441
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1155,9 +1443,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1155
1443
  });
1156
1444
  app.post('/api/stash/apply', async (req, resp) => {
1157
1445
  try {
1158
- const { index } = req.body;
1159
- await git.stash(['apply', `stash@{${index}}`]);
1160
- 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, []);
1161
1453
  }
1162
1454
  catch (err) {
1163
1455
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1165,9 +1457,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1165
1457
  });
1166
1458
  app.post('/api/stash/pop', async (req, resp) => {
1167
1459
  try {
1168
- const { index } = req.body;
1169
- await git.stash(['pop', `stash@{${index}}`]);
1170
- 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, []);
1171
1466
  }
1172
1467
  catch (err) {
1173
1468
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1176,8 +1471,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1176
1471
  app.post('/api/stash/drop', async (req, resp) => {
1177
1472
  try {
1178
1473
  const { index } = req.body;
1179
- await git.stash(['drop', `stash@{${index}}`]);
1180
- return resp.type('application/json').send({ success: true });
1474
+ await mutate(() => git.stash(['drop', `stash@{${index}}`]));
1475
+ return mutationResult(resp, []);
1181
1476
  }
1182
1477
  catch (err) {
1183
1478
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1217,14 +1512,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1217
1512
  });
1218
1513
  app.post('/api/tag/create', async (req, resp) => {
1219
1514
  try {
1220
- const { name, message, commit } = req.body;
1221
- if (message) {
1222
- await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1223
- }
1224
- else {
1225
- await git.tag([name, ...(commit ? [commit] : [])]);
1226
- }
1227
- 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);
1228
1550
  }
1229
1551
  catch (err) {
1230
1552
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1232,9 +1554,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1232
1554
  });
1233
1555
  app.post('/api/tag/delete', async (req, resp) => {
1234
1556
  try {
1235
- const { name } = req.body;
1236
- await git.tag(['-d', name]);
1237
- 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);
1238
1579
  }
1239
1580
  catch (err) {
1240
1581
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1242,12 +1583,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1242
1583
  });
1243
1584
  app.post('/api/tag/push', async (req, resp) => {
1244
1585
  try {
1245
- const { name, remote } = req.body;
1246
- if (!name) {
1247
- return resp.status(400).type('application/json').send({ error: 'name required' });
1248
- }
1249
- await git.push(remote || 'origin', `refs/tags/${name}`);
1250
- 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, []);
1251
1592
  }
1252
1593
  catch (err) {
1253
1594
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1266,9 +1607,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1266
1607
  });
1267
1608
  app.post('/api/pull', async (req, resp) => {
1268
1609
  try {
1269
- const { remote, branch, rebase } = req.body;
1270
- await git.pull(remote || 'origin', branch || undefined, rebase ? { '--rebase': null } : {});
1271
- 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, []);
1272
1624
  }
1273
1625
  catch (err) {
1274
1626
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2858,8 +3210,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2858
3210
  // ==================== Working Tree Actions ====================
2859
3211
  app.post('/api/reset', async (_req, resp) => {
2860
3212
  try {
2861
- await git.raw(['reset', '--hard', 'HEAD']);
2862
- return resp.type('application/json').send({ ok: true });
3213
+ await mutate(() => git.raw(['reset', '--hard', 'HEAD']));
3214
+ return mutationResult(resp, []);
2863
3215
  }
2864
3216
  catch (err) {
2865
3217
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2867,8 +3219,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2867
3219
  });
2868
3220
  app.post('/api/clean', async (_req, resp) => {
2869
3221
  try {
2870
- await git.raw(['clean', '-fd']);
2871
- return resp.type('application/json').send({ ok: true });
3222
+ await mutate(() => git.raw(['clean', '-fd']));
3223
+ return mutationResult(resp, []);
2872
3224
  }
2873
3225
  catch (err) {
2874
3226
  return resp.status(400).type('application/json').send({ error: err.message });