dsh-git-branch-manage 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { type CopyFilesSeams } from './copy-files.js';
6
6
  import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
7
- import type { CreateBranchResult, CreateWorktreeResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, PathExistsResult, PurgeDirectoryResult, RemoveWorktreeResult, RepoStatus, RouteError, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
7
+ import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateWorktreeResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, RemoveWorktreeResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
8
8
  /** Everything the handlers need from the host half. */
9
9
  export interface RouteDeps {
10
10
  exec: Exec;
@@ -46,7 +46,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
46
46
  /** One route outcome: HTTP status plus the JSON body. */
47
47
  export interface RouteOutcome {
48
48
  status: number;
49
- body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | RouteError;
49
+ body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | AiCommitMessageResult | RouteError;
50
50
  }
51
51
  /**
52
52
  * POST /group — git belonging facts for a batch of workspace directories.
@@ -120,7 +120,12 @@ export declare function handleStatus(deps: RouteDeps, path: string | undefined):
120
120
  */
121
121
  export declare function handleCreateWorktree(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
122
122
  /**
123
- * POST /switch 鈥?in-place branch switch of the main checkout.
123
+ * POST /switch — in-place branch switch of the QUERIED directory's own
124
+ * checkout. A main-checkout session switches the main checkout; a linked
125
+ * worktree session switches ITS worktree. (It used to always run in the
126
+ * main checkout: a worktree session's checkout appeared to succeed while its
127
+ * own branch never moved. git itself refuses a branch another worktree
128
+ * holds — an honest error beats moving a checkout nobody was looking at.)
124
129
  * @param deps - host dependencies.
125
130
  * @param body - parsed request body.
126
131
  */
@@ -229,3 +234,86 @@ export declare function handlePathExists(deps: RouteDeps, body: unknown): Promis
229
234
  * @param body - parsed request body: `{ path }`.
230
235
  */
231
236
  export declare function handleEnsureDirectory(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
237
+ /** Upper bound on the rows one /changes answer lists; a busier working tree
238
+ * is flagged `truncated` instead of flooding the panel. */
239
+ export declare const CHANGES_ROWS_LIMIT = 500;
240
+ /**
241
+ * POST /changes — the queried checkout's working-tree rows (staged,
242
+ * unstaged, untracked), straight from `git status --porcelain=v1 -z`,
243
+ * capped at {@link CHANGES_ROWS_LIMIT} rows (`truncated` flags the cut).
244
+ * @param deps - host dependencies.
245
+ * @param body - parsed request body: `{ repoPath }`.
246
+ */
247
+ export declare function handleChanges(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
248
+ /**
249
+ * POST /stage — stage (`git add`) or unstage (`git reset -q HEAD --`) the
250
+ * given relative paths of the queried checkout. Paths must be relative,
251
+ * `..`-free, and bounded ({@link STAGE_PATHS_LIMIT}); they are passed to git
252
+ * as execFile arguments, never through a shell.
253
+ * @param deps - host dependencies.
254
+ * @param body - parsed request body: `{ repoPath, paths, stage }`.
255
+ */
256
+ export declare function handleStage(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
257
+ /** Upper bound for one commit message (characters): the shared wire constant,
258
+ * so the client's input cap and this rejection can never drift apart. */
259
+ /**
260
+ * POST /commit — commit the queried checkout's index (`git commit -m`).
261
+ * Hooks run as usual; an empty index is git's own refusal, surfaced verbatim.
262
+ * @param deps - host dependencies.
263
+ * @param body - parsed request body: `{ repoPath, message }`.
264
+ */
265
+ export declare function handleCommit(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
266
+ /** Default and maximum page size of POST /log. */
267
+ export declare const LOG_LIMIT_DEFAULT = 50;
268
+ export declare const LOG_LIMIT_MAX = 200;
269
+ /**
270
+ * POST /log — one page of the queried checkout's history. The optional `ref`
271
+ * is verified server-side (`rev-parse --verify`), an unborn checkout answers
272
+ * an empty page, and everything else surfaces git's own stderr.
273
+ * @param deps - host dependencies.
274
+ * @param body - parsed request body: `{ repoPath, limit?, skip?, ref? }`.
275
+ */
276
+ export declare function handleLog(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
277
+ /**
278
+ * POST /commit-files — the files one commit of the queried checkout changed.
279
+ * The hash is verified server-side; unknown revisions answer 400.
280
+ * @param deps - host dependencies.
281
+ * @param body - parsed request body: `{ repoPath, hash }`.
282
+ */
283
+ export declare function handleCommitFiles(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
284
+ /** Narrow face of the host llm runtime the AI route needs. The service is
285
+ * registered by the dsh-llm host plugin; the panel mounts this route only
286
+ * where that injection resolves. Message content is the model-facing BLOCK
287
+ * list (a plain string is rejected), and visible text arrives only as
288
+ * `text-delta` chunks (reasoning deltas carry text too and must not fold). */
289
+ export interface LlmStreamRuntime {
290
+ stream(options: {
291
+ provider: string;
292
+ model: string;
293
+ reasoningEffort?: string;
294
+ messages: ReadonlyArray<{
295
+ role: 'user';
296
+ content: ReadonlyArray<{
297
+ type: 'text';
298
+ text: string;
299
+ }>;
300
+ }>;
301
+ system?: string;
302
+ temperature?: number;
303
+ maxTokens?: number;
304
+ }): AsyncIterable<{
305
+ type: string;
306
+ text?: string;
307
+ }>;
308
+ }
309
+ /**
310
+ * POST /ai-commit-message — generate a commit message from the checkout's
311
+ * diff with ONE one-shot llm call. The model route (provider/model/effort)
312
+ * arrives from the requesting session's own selection, so the generation uses
313
+ * the model the user is already talking to; the diff is truncated and the
314
+ * reply folded to plain text.
315
+ * @param deps - host dependencies.
316
+ * @param llm - the resolved llm runtime (the route is mounted only with one).
317
+ * @param body - parsed request body: `{ repoPath, provider, model, effort?, hint? }`.
318
+ */
319
+ export declare function handleAiCommitMessage(deps: RouteDeps, llm: LlmStreamRuntime, body: unknown): Promise<RouteOutcome>;
package/lib/routes.js CHANGED
@@ -5,9 +5,10 @@
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
7
7
  import { runPostCreateCopy } from './copy-files.js';
8
- import { appendWorktreeExclude, fsExcludeSeams, GitError, addWorktree, addWorktreeCutout, createBranch, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, probeRepo, probeWorkspaceGit, removeWorktree, renameBranch, resolveBranch, pushBranch, switchBranch, updateBranch } from './git.js';
8
+ import { appendWorktreeExclude, fsExcludeSeams, GitError, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, logHistory, probeRepo, probeWorkspaceGit, removeWorktree, renameBranch, resolveBranch, pushBranch, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
9
9
  import { isAbsoluteConfigPath, sanitizeBranchDir } from './normalize.js';
10
10
  import { resolveRootDir } from './settings.js';
11
+ import { COMMIT_MESSAGE_LIMIT } from './wire.js';
11
12
  /** Real fs-backed one-level directory listing (names only). Plain FILES are
12
13
  * filtered out — the storage root can carry strays (a legacy settings.json
13
14
  * was seen in the wild), and the scan lists worktree SLOTS, not entries. */
@@ -321,18 +322,19 @@ export async function handleStatus(deps, path) {
321
322
  main: facts.main,
322
323
  currentBranch: facts.currentBranch,
323
324
  branches: facts.branches,
325
+ remoteBranches: facts.remoteBranches,
324
326
  worktrees: facts.worktrees,
325
327
  rootDir,
326
328
  },
327
329
  };
328
330
  }
329
331
  /** Validate a JSON body object with exactly the expected keys. */
330
- function readBody(body, keys, booleanKeys = [], optionalKeys = []) {
332
+ function readBody(body, keys, booleanKeys = [], optionalKeys = [], numberKeys = []) {
331
333
  if (typeof body !== 'object' || body === null || Array.isArray(body))
332
334
  return fail(400, 'request body must be a JSON object');
333
335
  const record = body;
334
336
  for (const key of Object.keys(record)) {
335
- if (!keys.includes(key) && !booleanKeys.includes(key) && !optionalKeys.includes(key)) {
337
+ if (!keys.includes(key) && !booleanKeys.includes(key) && !optionalKeys.includes(key) && !numberKeys.includes(key)) {
336
338
  return fail(400, `unknown body key "${key}"`);
337
339
  }
338
340
  }
@@ -352,6 +354,12 @@ function readBody(body, keys, booleanKeys = [], optionalKeys = []) {
352
354
  return fail(400, `body key "${key}" must be a string`);
353
355
  }
354
356
  }
357
+ for (const key of numberKeys) {
358
+ // Optional number: absent stays absent (`limit?: number`), present must type-check.
359
+ if (record[key] !== undefined && typeof record[key] !== 'number') {
360
+ return fail(400, `body key "${key}" must be a number`);
361
+ }
362
+ }
355
363
  return record;
356
364
  }
357
365
  /** Discriminator: a RouteOutcome carries the `status`/`body` pair no request body has. */
@@ -463,7 +471,12 @@ export async function handleCreateWorktree(deps, body) {
463
471
  }
464
472
  }
465
473
  /**
466
- * POST /switch 鈥?in-place branch switch of the main checkout.
474
+ * POST /switch — in-place branch switch of the QUERIED directory's own
475
+ * checkout. A main-checkout session switches the main checkout; a linked
476
+ * worktree session switches ITS worktree. (It used to always run in the
477
+ * main checkout: a worktree session's checkout appeared to succeed while its
478
+ * own branch never moved. git itself refuses a branch another worktree
479
+ * holds — an honest error beats moving a checkout nobody was looking at.)
467
480
  * @param deps - host dependencies.
468
481
  * @param body - parsed request body.
469
482
  */
@@ -480,7 +493,7 @@ export async function handleSwitch(deps, body) {
480
493
  const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
481
494
  if (facts === undefined)
482
495
  return fail(400, `"${repoPath}" is not inside a git repository`);
483
- const switched = await switchBranch(deps.exec, facts.repoRoot, branch);
496
+ const switched = await switchBranch(deps.exec, facts.toplevel, branch);
484
497
  const result = { branch: switched };
485
498
  return { status: 200, body: result };
486
499
  }
@@ -862,3 +875,241 @@ export async function handleEnsureDirectory(deps, body) {
862
875
  return fail(500, error instanceof Error ? error.message : String(error));
863
876
  }
864
877
  }
878
+ /* ------------------------------------------------------------------ *
879
+ * Git panel routes: working tree, staging, commit, history, AI message.
880
+ * All of them act on the QUERIED checkout (repoPath = any directory inside
881
+ * it), so a linked-worktree session reads and mutates its own worktree.
882
+ * ------------------------------------------------------------------ */
883
+ /** Upper bound on paths one /stage request accepts. */
884
+ const STAGE_PATHS_LIMIT = 500;
885
+ /** Upper bound on the rows one /changes answer lists; a busier working tree
886
+ * is flagged `truncated` instead of flooding the panel. */
887
+ export const CHANGES_ROWS_LIMIT = 500;
888
+ /** Common validation: `{ repoPath }`-shaped bodies and their checkout directory. */
889
+ function readRepoPath(parsed) {
890
+ if (isOutcome(parsed))
891
+ return parsed;
892
+ if (!isAbsoluteDir(parsed.repoPath))
893
+ return fail(400, '"repoPath" must be an absolute directory');
894
+ return parsed.repoPath;
895
+ }
896
+ /** Map a git failure on the queried checkout to the uniform 400 envelope. */
897
+ function checkoutFailure(error) {
898
+ const message = error instanceof Error ? error.message : String(error);
899
+ return fail(400, message);
900
+ }
901
+ /**
902
+ * POST /changes — the queried checkout's working-tree rows (staged,
903
+ * unstaged, untracked), straight from `git status --porcelain=v1 -z`,
904
+ * capped at {@link CHANGES_ROWS_LIMIT} rows (`truncated` flags the cut).
905
+ * @param deps - host dependencies.
906
+ * @param body - parsed request body: `{ repoPath }`.
907
+ */
908
+ export async function handleChanges(deps, body) {
909
+ const parsed = readBody(body, ['repoPath']);
910
+ if (isOutcome(parsed))
911
+ return parsed;
912
+ const repoPath = readRepoPath(parsed);
913
+ if (typeof repoPath !== 'string')
914
+ return repoPath;
915
+ try {
916
+ const { changes } = await workingChanges(deps.exec, repoPath);
917
+ if (changes.length > CHANGES_ROWS_LIMIT) {
918
+ return { status: 200, body: { changes: changes.slice(0, CHANGES_ROWS_LIMIT), truncated: true } };
919
+ }
920
+ return { status: 200, body: { changes } };
921
+ }
922
+ catch (error) {
923
+ return checkoutFailure(error);
924
+ }
925
+ }
926
+ /**
927
+ * POST /stage — stage (`git add`) or unstage (`git reset -q HEAD --`) the
928
+ * given relative paths of the queried checkout. Paths must be relative,
929
+ * `..`-free, and bounded ({@link STAGE_PATHS_LIMIT}); they are passed to git
930
+ * as execFile arguments, never through a shell.
931
+ * @param deps - host dependencies.
932
+ * @param body - parsed request body: `{ repoPath, paths, stage }`.
933
+ */
934
+ export async function handleStage(deps, body) {
935
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
936
+ return fail(400, 'request body must be a JSON object');
937
+ const record = body;
938
+ for (const key of Object.keys(record)) {
939
+ if (key !== 'repoPath' && key !== 'paths' && key !== 'stage')
940
+ return fail(400, `unknown body key "${key}"`);
941
+ }
942
+ const repoPathRaw = record.repoPath;
943
+ if (typeof repoPathRaw !== 'string' || !isAbsoluteDir(repoPathRaw))
944
+ return fail(400, '"repoPath" must be an absolute directory');
945
+ if (record.stage !== true && record.stage !== false)
946
+ return fail(400, 'body key "stage" must be a boolean');
947
+ const paths = record.paths;
948
+ if (!Array.isArray(paths) || !paths.every(item => typeof item === 'string')) {
949
+ return fail(400, 'body key "paths" must be an array of relative paths');
950
+ }
951
+ if (paths.length === 0)
952
+ return fail(400, 'body key "paths" must not be empty');
953
+ if (paths.length > STAGE_PATHS_LIMIT)
954
+ return fail(400, `body key "paths" accepts at most ${String(STAGE_PATHS_LIMIT)} paths`);
955
+ const bad = paths.find(path => !isStageablePath(path));
956
+ if (bad !== undefined)
957
+ return fail(400, `"${bad}" is not a stageable relative path`);
958
+ try {
959
+ await stagePaths(deps.exec, repoPathRaw, paths, record.stage);
960
+ return { status: 200, body: { staged: true } };
961
+ }
962
+ catch (error) {
963
+ return checkoutFailure(error);
964
+ }
965
+ }
966
+ /** Upper bound for one commit message (characters): the shared wire constant,
967
+ * so the client's input cap and this rejection can never drift apart. */
968
+ /**
969
+ * POST /commit — commit the queried checkout's index (`git commit -m`).
970
+ * Hooks run as usual; an empty index is git's own refusal, surfaced verbatim.
971
+ * @param deps - host dependencies.
972
+ * @param body - parsed request body: `{ repoPath, message }`.
973
+ */
974
+ export async function handleCommit(deps, body) {
975
+ const parsed = readBody(body, ['repoPath', 'message']);
976
+ if (isOutcome(parsed))
977
+ return parsed;
978
+ const repoPath = readRepoPath(parsed);
979
+ if (typeof repoPath !== 'string')
980
+ return repoPath;
981
+ const message = parsed.message;
982
+ if (message.trim() === '')
983
+ return fail(400, '"message" must not be blank');
984
+ if (message.length > COMMIT_MESSAGE_LIMIT)
985
+ return fail(400, `"message" accepts at most ${String(COMMIT_MESSAGE_LIMIT)} characters`);
986
+ try {
987
+ const result = await commitIndex(deps.exec, repoPath, message);
988
+ return { status: 200, body: result };
989
+ }
990
+ catch (error) {
991
+ return checkoutFailure(error);
992
+ }
993
+ }
994
+ /** Default and maximum page size of POST /log. */
995
+ export const LOG_LIMIT_DEFAULT = 50;
996
+ export const LOG_LIMIT_MAX = 200;
997
+ /**
998
+ * POST /log — one page of the queried checkout's history. The optional `ref`
999
+ * is verified server-side (`rev-parse --verify`), an unborn checkout answers
1000
+ * an empty page, and everything else surfaces git's own stderr.
1001
+ * @param deps - host dependencies.
1002
+ * @param body - parsed request body: `{ repoPath, limit?, skip?, ref? }`.
1003
+ */
1004
+ export async function handleLog(deps, body) {
1005
+ const parsed = readBody(body, ['repoPath'], [], ['ref'], ['limit', 'skip']);
1006
+ if (isOutcome(parsed))
1007
+ return parsed;
1008
+ const repoPath = readRepoPath(parsed);
1009
+ if (typeof repoPath !== 'string')
1010
+ return repoPath;
1011
+ const limit = parsed.limit ?? LOG_LIMIT_DEFAULT;
1012
+ const skip = parsed.skip ?? 0;
1013
+ if (!Number.isInteger(limit) || limit < 1 || limit > LOG_LIMIT_MAX) {
1014
+ return fail(400, `"limit" must be an integer between 1 and ${String(LOG_LIMIT_MAX)}`);
1015
+ }
1016
+ if (!Number.isInteger(skip) || skip < 0)
1017
+ return fail(400, '"skip" must be an integer >= 0');
1018
+ const ref = parsed.ref;
1019
+ if (ref !== undefined && (ref.trim() === '' || ref.length > 300 || ref.startsWith('-'))) {
1020
+ return fail(400, '"ref" must be a non-empty revision name');
1021
+ }
1022
+ try {
1023
+ const commits = await logHistory(deps.exec, repoPath, limit, skip, ref);
1024
+ return { status: 200, body: { commits } };
1025
+ }
1026
+ catch (error) {
1027
+ return checkoutFailure(error);
1028
+ }
1029
+ }
1030
+ /**
1031
+ * POST /commit-files — the files one commit of the queried checkout changed.
1032
+ * The hash is verified server-side; unknown revisions answer 400.
1033
+ * @param deps - host dependencies.
1034
+ * @param body - parsed request body: `{ repoPath, hash }`.
1035
+ */
1036
+ export async function handleCommitFiles(deps, body) {
1037
+ const parsed = readBody(body, ['repoPath', 'hash']);
1038
+ if (isOutcome(parsed))
1039
+ return parsed;
1040
+ const repoPath = readRepoPath(parsed);
1041
+ if (typeof repoPath !== 'string')
1042
+ return repoPath;
1043
+ const hash = parsed.hash;
1044
+ if (!/^[0-9a-fA-F]{4,64}$/.test(hash))
1045
+ return fail(400, '"hash" must be a git object id');
1046
+ try {
1047
+ const files = await commitFiles(deps.exec, repoPath, hash);
1048
+ return { status: 200, body: { hash, files } };
1049
+ }
1050
+ catch (error) {
1051
+ return checkoutFailure(error);
1052
+ }
1053
+ }
1054
+ /** Bound for the AI-generated message (characters). */
1055
+ const AI_MESSAGE_LIMIT = 2000;
1056
+ /**
1057
+ * POST /ai-commit-message — generate a commit message from the checkout's
1058
+ * diff with ONE one-shot llm call. The model route (provider/model/effort)
1059
+ * arrives from the requesting session's own selection, so the generation uses
1060
+ * the model the user is already talking to; the diff is truncated and the
1061
+ * reply folded to plain text.
1062
+ * @param deps - host dependencies.
1063
+ * @param llm - the resolved llm runtime (the route is mounted only with one).
1064
+ * @param body - parsed request body: `{ repoPath, provider, model, effort?, hint? }`.
1065
+ */
1066
+ export async function handleAiCommitMessage(deps, llm, body) {
1067
+ const parsed = readBody(body, ['repoPath', 'provider', 'model'], [], ['effort', 'hint']);
1068
+ if (isOutcome(parsed))
1069
+ return parsed;
1070
+ const repoPath = readRepoPath(parsed);
1071
+ if (typeof repoPath !== 'string')
1072
+ return repoPath;
1073
+ if (parsed.provider.trim() === '' || parsed.model.trim() === '') {
1074
+ return fail(400, '"provider" and "model" must be non-empty');
1075
+ }
1076
+ if (parsed.hint !== undefined && parsed.hint.length > 2000)
1077
+ return fail(400, '"hint" accepts at most 2000 characters');
1078
+ try {
1079
+ const { diff, recentSubjects, staged } = await collectAiDiff(deps.exec, repoPath);
1080
+ if (diff.trim() === '')
1081
+ return fail(400, 'no changes to describe (the working tree and index are clean)');
1082
+ const style = recentSubjects.length > 0
1083
+ ? `Recent commit subjects of this repository (follow their language and style):\n${recentSubjects.map(subject => `- ${subject}`).join('\n')}`
1084
+ : '';
1085
+ const userText = [
1086
+ `Generate a git commit message for the ${staged ? 'staged' : 'working-tree'} changes below.`,
1087
+ style,
1088
+ parsed.hint === undefined ? undefined : `User hint (rewrite or style request): ${parsed.hint}`,
1089
+ 'Diff:',
1090
+ diff,
1091
+ ].filter(part => part !== undefined).join('\n\n');
1092
+ let text = '';
1093
+ for await (const chunk of llm.stream({
1094
+ provider: parsed.provider,
1095
+ model: parsed.model,
1096
+ ...(parsed.effort === undefined || parsed.effort === '' ? {} : { reasoningEffort: parsed.effort }),
1097
+ messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }],
1098
+ system: 'You write git commit messages. Reply with the message ONLY: one conventional-commit subject line (matching the repository language), optionally followed by a blank line and a short body of bullet points. No explanations, no markdown fences.',
1099
+ temperature: 0.2,
1100
+ maxTokens: 500,
1101
+ })) {
1102
+ if (chunk.type === 'text-delta')
1103
+ text += chunk.text ?? '';
1104
+ }
1105
+ text = text.replace(/^```[a-z]*\n?|```\n?$/g, '').trim();
1106
+ if (text === '')
1107
+ return fail(502, 'the model returned an empty message');
1108
+ if (text.length > AI_MESSAGE_LIMIT)
1109
+ text = text.slice(0, AI_MESSAGE_LIMIT);
1110
+ return { status: 200, body: { message: text } };
1111
+ }
1112
+ catch (error) {
1113
+ return checkoutFailure(error);
1114
+ }
1115
+ }
package/lib/wire.d.ts CHANGED
@@ -43,6 +43,23 @@ export declare const ROUTE_WORKTREES_ALL = "/plugin/git-worktree/worktrees-all";
43
43
  /** POST ROUTE_PREFIX/purge — delete a NON-git directory sitting DIRECTLY in
44
44
  * the storage root (an orphaned leftover whose .git is already gone). */
45
45
  export declare const ROUTE_PURGE = "/plugin/git-worktree/purge";
46
+ /** POST ROUTE_PREFIX/changes — working-tree changes (staged/unstaged rows). */
47
+ export declare const ROUTE_CHANGES = "/plugin/git-worktree/changes";
48
+ /** POST ROUTE_PREFIX/stage — batch stage (git add) or unstage paths. */
49
+ export declare const ROUTE_STAGE = "/plugin/git-worktree/stage";
50
+ /** POST ROUTE_PREFIX/commit — commit the index with a message. */
51
+ export declare const ROUTE_COMMIT = "/plugin/git-worktree/commit";
52
+ /** POST ROUTE_PREFIX/log — paginated commit history of the checkout. */
53
+ export declare const ROUTE_LOG = "/plugin/git-worktree/log";
54
+ /** POST ROUTE_PREFIX/commit-files — the files one commit changed. */
55
+ export declare const ROUTE_COMMIT_FILES = "/plugin/git-worktree/commit-files";
56
+ /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
57
+ * the staged diff (mounted only where the host provides the llm service). */
58
+ export declare const ROUTE_AI_COMMIT_MESSAGE = "/plugin/git-worktree/ai-commit-message";
59
+ /** Upper bound for one commit message (characters). The host rejects longer
60
+ * bodies and the client caps its input at the same value, so the two ends
61
+ * can never disagree. */
62
+ export declare const COMMIT_MESSAGE_LIMIT = 2000;
46
63
  /** One selectable branch row. */
47
64
  export interface BranchEntry {
48
65
  /** Display name: a bare local name (`main`) or `<remote>/<name>`. */
@@ -82,6 +99,11 @@ export type RepoStatus = {
82
99
  /** Local branches plus every remote's remote-only branches (those
83
100
  * without a same-named local twin; `<remote>/HEAD` dropped). */
84
101
  branches: BranchEntry[];
102
+ /** The FULL remote view: every remote branch, local twins included
103
+ * (the git panel's remote tree; `<remote>/HEAD` and bare leftover
104
+ * refs dropped). Absent from hosts serving an older build of this
105
+ * route — the client falls back to filtering `branches`. */
106
+ remoteBranches?: BranchEntry[];
85
107
  /** Every worktree of the repository, main first (git order). */
86
108
  worktrees: WorktreeEntry[];
87
109
  /** Resolved worktree storage root (absolute) — what the settings say today. */
@@ -364,3 +386,137 @@ export interface PurgeDirectoryResult {
364
386
  path: string;
365
387
  removed: boolean;
366
388
  }
389
+ /** Status letter of one working-tree or commit file change. `U` never comes
390
+ * from a commit's file list — it marks an UNMERGED working-tree path. */
391
+ export type ChangeStatus = 'A' | 'M' | 'D' | 'R' | 'C' | 'T' | 'U';
392
+ /** One working-tree change row of POST changes. */
393
+ export interface WorktreeChange {
394
+ /** New path, relative to the repository root. */
395
+ path: string;
396
+ /** Original path for a rename/copy. */
397
+ oldPath?: string;
398
+ /** Index (staged) status; absent when the index matches HEAD. */
399
+ staged?: ChangeStatus;
400
+ /** Worktree (unstaged) status; absent when the worktree matches the index. */
401
+ unstaged?: ChangeStatus;
402
+ /** True for an untracked path (neither staged nor tracked). */
403
+ untracked?: boolean;
404
+ }
405
+ /** POST changes request body. */
406
+ export interface ChangesBody {
407
+ /** Any directory inside the repository (the queried checkout). */
408
+ repoPath: string;
409
+ }
410
+ /** POST changes response body. */
411
+ export interface ChangesResult {
412
+ /** One row per changed path, in git status order; capped server-side. */
413
+ changes: WorktreeChange[];
414
+ /** True when the working tree had more rows than the server cap and only
415
+ * the first ones are listed here. */
416
+ truncated?: boolean;
417
+ }
418
+ /** POST stage request body. */
419
+ export interface StageBody {
420
+ /** Any directory inside the repository (the queried checkout). */
421
+ repoPath: string;
422
+ /** Relative paths to (un)stage; they must come from POST changes output. */
423
+ paths: string[];
424
+ /** True stages (git add); false unstages (git reset / git rm --cached). */
425
+ stage: boolean;
426
+ }
427
+ /** POST stage response body. */
428
+ export interface StageResult {
429
+ /** Always true on 200 — the index now reflects the request. */
430
+ staged: boolean;
431
+ }
432
+ /** POST commit request body. */
433
+ export interface CommitBody {
434
+ /** Any directory inside the repository (the queried checkout). */
435
+ repoPath: string;
436
+ /** The commit message, verbatim (git -m). */
437
+ message: string;
438
+ }
439
+ /** POST commit response body. */
440
+ export interface CommitResult {
441
+ /** The created commit's full hash. */
442
+ hash: string;
443
+ /** The commit subject (first line of the message, trimmed). */
444
+ subject: string;
445
+ }
446
+ /** One commit row of POST log. */
447
+ export interface CommitEntry {
448
+ /** Full hash. */
449
+ hash: string;
450
+ /** Abbreviated hash (%h). */
451
+ short: string;
452
+ authorName: string;
453
+ authorEmail: string;
454
+ /** Author date, epoch milliseconds. */
455
+ date: number;
456
+ /** Parent hashes; more than one marks a merge commit. */
457
+ parents: string[];
458
+ /** The commit subject (%s). */
459
+ subject: string;
460
+ /** The commit body (%b); empty when none. */
461
+ body: string;
462
+ /** Decorations (%D), raw segments: `HEAD -> main`, `tag: v1.0.2`, `origin/main`. */
463
+ refs: string[];
464
+ }
465
+ /** POST log request body. */
466
+ export interface LogBody {
467
+ /** Any directory inside the repository (the queried checkout). */
468
+ repoPath: string;
469
+ /** Page size; default 50, capped at 200. */
470
+ limit?: number;
471
+ /** Offset into the filtered history; default 0. */
472
+ skip?: number;
473
+ /** Optional starting ref (a branch name, `HEAD`, …); validated server-side. */
474
+ ref?: string;
475
+ }
476
+ /** POST log response body. */
477
+ export interface LogResult {
478
+ commits: CommitEntry[];
479
+ }
480
+ /** One file row of POST commit-files. */
481
+ export interface CommitFileEntry {
482
+ /** New path, relative to the repository root. */
483
+ path: string;
484
+ /** Original path for a rename/copy. */
485
+ oldPath?: string;
486
+ status: ChangeStatus;
487
+ /** Added lines; absent for a binary file or when parsing fell short. */
488
+ insertions?: number;
489
+ /** Deleted lines; same conditions. */
490
+ deletions?: number;
491
+ /** True when git numstat reported `-` (binary content). */
492
+ binary?: boolean;
493
+ }
494
+ /** POST commit-files request body. */
495
+ export interface CommitFilesBody {
496
+ /** Any directory inside the repository (the queried checkout). */
497
+ repoPath: string;
498
+ /** The commit hash (validated server-side). */
499
+ hash: string;
500
+ }
501
+ /** POST commit-files response body. */
502
+ export interface CommitFilesResult {
503
+ hash: string;
504
+ files: CommitFileEntry[];
505
+ }
506
+ /** POST ai-commit-message request body. */
507
+ export interface AiCommitMessageBody {
508
+ /** Any directory inside the repository (the queried checkout). */
509
+ repoPath: string;
510
+ /** The model route to call, as selected by the requesting session. */
511
+ provider: string;
512
+ model: string;
513
+ /** Adapter-owned reasoning effort for the exact model; absent = default. */
514
+ effort?: string;
515
+ /** Optional user hint (an existing draft to rewrite, or style directions). */
516
+ hint?: string;
517
+ }
518
+ /** POST ai-commit-message response body. */
519
+ export interface AiCommitMessageResult {
520
+ /** The generated commit message (subject, optionally followed by a body). */
521
+ message: string;
522
+ }
package/lib/wire.js CHANGED
@@ -43,3 +43,20 @@ export const ROUTE_WORKTREES_ALL = `${ROUTE_PREFIX}/worktrees-all`;
43
43
  /** POST ROUTE_PREFIX/purge — delete a NON-git directory sitting DIRECTLY in
44
44
  * the storage root (an orphaned leftover whose .git is already gone). */
45
45
  export const ROUTE_PURGE = `${ROUTE_PREFIX}/purge`;
46
+ /** POST ROUTE_PREFIX/changes — working-tree changes (staged/unstaged rows). */
47
+ export const ROUTE_CHANGES = `${ROUTE_PREFIX}/changes`;
48
+ /** POST ROUTE_PREFIX/stage — batch stage (git add) or unstage paths. */
49
+ export const ROUTE_STAGE = `${ROUTE_PREFIX}/stage`;
50
+ /** POST ROUTE_PREFIX/commit — commit the index with a message. */
51
+ export const ROUTE_COMMIT = `${ROUTE_PREFIX}/commit`;
52
+ /** POST ROUTE_PREFIX/log — paginated commit history of the checkout. */
53
+ export const ROUTE_LOG = `${ROUTE_PREFIX}/log`;
54
+ /** POST ROUTE_PREFIX/commit-files — the files one commit changed. */
55
+ export const ROUTE_COMMIT_FILES = `${ROUTE_PREFIX}/commit-files`;
56
+ /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
57
+ * the staged diff (mounted only where the host provides the llm service). */
58
+ export const ROUTE_AI_COMMIT_MESSAGE = `${ROUTE_PREFIX}/ai-commit-message`;
59
+ /** Upper bound for one commit message (characters). The host rejects longer
60
+ * bodies and the client caps its input at the same value, so the two ends
61
+ * can never disagree. */
62
+ export const COMMIT_MESSAGE_LIMIT = 2000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-branch-manage",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",