dsh-git-branch-manage 1.0.5 → 1.0.7

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/git.d.ts CHANGED
@@ -182,6 +182,28 @@ export declare function switchBranch(exec: Exec, checkoutDir: string, branch: st
182
182
  * start point with `checkout` false).
183
183
  */
184
184
  export declare function createBranch(exec: Exec, cwd: string, name: string, from?: string, checkout?: boolean): Promise<string>;
185
+ /** Outcome of {@link createTag}: the tag created and the commit it points at. */
186
+ export interface TagOutcome {
187
+ tag: string;
188
+ hash: string;
189
+ }
190
+ /**
191
+ * Create a tag at an existing commit (`git tag`). Tags are repository-wide
192
+ * refs, so the command runs at the repository root.
193
+ *
194
+ * A message makes it an ANNOTATED tag (`-a -m`) — the only shape that carries
195
+ * an author and date, and what `git describe` prefers; without one the tag is
196
+ * lightweight and is just an alias for the commit. Both are created locally
197
+ * only: pushing a tag is a deliberate, separate publish step that this panel
198
+ * does not perform on a menu click.
199
+ * @param exec - executor seam.
200
+ * @param repoRoot - main worktree directory.
201
+ * @param name - tag name (git validates; failures raise GitError).
202
+ * @param target - commit to tag; absent tags the repository's current HEAD.
203
+ * @param message - optional annotation message; non-empty = annotated tag.
204
+ * @returns the tag name and the commit hash it points at.
205
+ */
206
+ export declare function createTag(exec: Exec, repoRoot: string, name: string, target?: string, message?: string): Promise<TagOutcome>;
185
207
  /**
186
208
  * Rename a LOCAL branch (`git branch -m`): branch refs are repository-wide,
187
209
  * so the command runs at the repository root and every worktree HEAD pointing
@@ -279,6 +301,57 @@ export interface PushOutcome {
279
301
  * push-specific diagnostic is preserved; in that case `pushed` is false.
280
302
  */
281
303
  export declare function pushBranch(exec: Exec, cwd: string): Promise<PushOutcome>;
304
+ /** Every LOCAL tag name of the repository, in git's own sort order. */
305
+ export declare function listTagNames(exec: Exec, cwd: string): Promise<string[]>;
306
+ /** Where a one-off push should go, or why no remote could be picked. */
307
+ export type PushRemoteChoice = {
308
+ readonly ok: true;
309
+ readonly remote: string;
310
+ } | {
311
+ readonly ok: false;
312
+ readonly message: string;
313
+ };
314
+ /**
315
+ * Pick the remote ONE tag push goes to, in fallback order: the checked-out
316
+ * branch's upstream remote, `remote.pushDefault`, then the only remote there
317
+ * is. Ambiguity is reported instead of guessed — a tag pushed to the wrong
318
+ * remote is a published mistake, not an inconvenience.
319
+ * @param exec - executor seam.
320
+ * @param cwd - directory whose checked-out branch supplies the upstream.
321
+ * @returns the remote name, or the caller-side reason none could be chosen.
322
+ */
323
+ export declare function resolvePushRemote(exec: Exec, cwd: string): Promise<PushRemoteChoice>;
324
+ /** Outcome of {@link pushTag}: the tag, where it went, and whether it moved. */
325
+ export interface TagPushOutcome {
326
+ tag: string;
327
+ remote: string;
328
+ pushed: boolean;
329
+ }
330
+ /**
331
+ * Push ONE existing local tag to `remote` (`git push <remote>
332
+ * refs/tags/<name>:refs/tags/<name>`) — the publish step a tag needs and a
333
+ * branch push never performs: `git push` sends the current branch alone, so a
334
+ * tag made on an already-pushed commit is invisible to the remote.
335
+ *
336
+ * The tag must already exist locally (the route verifies it against
337
+ * {@link listTagNames}); a remote that already carries the SAME tag object is
338
+ * reported as `pushed: false` without running the push at all. The refspec
339
+ * always names both sides explicitly, so no configured push refspec (a mirror,
340
+ * a `push.default=upstream` branch) can widen what a menu click publishes, and
341
+ * tags are never deleted or overwritten: `--force` is deliberately absent, and
342
+ * a remote tag pointing somewhere else makes git refuse (mapped to 400).
343
+ *
344
+ * The cn-accelerate rewrite is never injected here (no {@link accelArgs}
345
+ * parameter exists, like {@link pushBranch}): a mirror has no `receive-pack` —
346
+ * and the pre-push probe must see the very remote the push targets, or the
347
+ * `pushed` flag would compare two different servers.
348
+ * @param exec - executor seam.
349
+ * @param cwd - any directory inside the repository (tag refs are shared).
350
+ * @param name - an existing local tag name.
351
+ * @param remote - the remote to publish to (see {@link resolvePushRemote}).
352
+ * @returns the tag, the remote, and whether the push had to run.
353
+ */
354
+ export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string): Promise<TagPushOutcome>;
282
355
  /** Guard for route inputs: a non-empty absolute directory path. */
283
356
  export declare function isAbsoluteDir(value: string): boolean;
284
357
  /** Pre-delete facts of one worktree directory, for the remove-confirm dialog. */
@@ -447,18 +520,34 @@ export interface CommitEntryRaw {
447
520
  body: string;
448
521
  refs: string[];
449
522
  }
523
+ /** The extra log filters the history card's toolbar drives. */
524
+ export interface LogFilter {
525
+ /** Every ref (local + remote branches, tags) instead of the checkout's
526
+ * HEAD — the history card's default view. Ignored when `ref` is given. */
527
+ all?: boolean;
528
+ /** Committer-author substring/regex, forwarded as `--author=<value>`. */
529
+ author?: string;
530
+ /** Lower/upper bound on the commit date, epoch milliseconds. */
531
+ since?: number;
532
+ until?: number;
533
+ }
450
534
  /**
451
535
  * Paginated history of the queried checkout. `ref` is verified with
452
536
  * `rev-parse --verify` first, so an arbitrary string can never reach the log
453
537
  * arguments as anything but a resolvable rev. An unborn checkout (no commits
454
538
  * yet) answers an empty page instead of an error.
539
+ *
540
+ * `--topo-order` is unconditional: the history card draws lane lines from
541
+ * `parents`, and a pure date order would interleave a branch's commits
542
+ * before their merge base, leaving edges pointing at rows not yet painted.
455
543
  * @param exec - executor seam.
456
544
  * @param cwd - the checkout directory.
457
545
  * @param limit - page size (1..200, validated upstream).
458
546
  * @param skip - offset (>= 0, validated upstream).
459
547
  * @param ref - optional starting rev; absent = the checkout's HEAD.
548
+ * @param filter - optional all-refs/author/date window.
460
549
  */
461
- export declare function logHistory(exec: Exec, cwd: string, limit: number, skip: number, ref?: string): Promise<CommitEntryRaw[]>;
550
+ export declare function logHistory(exec: Exec, cwd: string, limit: number, skip: number, ref?: string, filter?: LogFilter): Promise<CommitEntryRaw[]>;
462
551
  /** One file row of {@link commitFiles}. */
463
552
  export interface CommitFileRow {
464
553
  path: string;
package/lib/git.js CHANGED
@@ -433,6 +433,35 @@ export async function createBranch(exec, cwd, name, from, checkout = false) {
433
433
  await git(exec, cwd, ['branch', name, from]);
434
434
  return name;
435
435
  }
436
+ /**
437
+ * Create a tag at an existing commit (`git tag`). Tags are repository-wide
438
+ * refs, so the command runs at the repository root.
439
+ *
440
+ * A message makes it an ANNOTATED tag (`-a -m`) — the only shape that carries
441
+ * an author and date, and what `git describe` prefers; without one the tag is
442
+ * lightweight and is just an alias for the commit. Both are created locally
443
+ * only: pushing a tag is a deliberate, separate publish step that this panel
444
+ * does not perform on a menu click.
445
+ * @param exec - executor seam.
446
+ * @param repoRoot - main worktree directory.
447
+ * @param name - tag name (git validates; failures raise GitError).
448
+ * @param target - commit to tag; absent tags the repository's current HEAD.
449
+ * @param message - optional annotation message; non-empty = annotated tag.
450
+ * @returns the tag name and the commit hash it points at.
451
+ */
452
+ export async function createTag(exec, repoRoot, name, target, message) {
453
+ const text = message?.trim() ?? '';
454
+ const args = ['tag'];
455
+ if (text !== '')
456
+ args.push('-a', name, '-m', text);
457
+ else
458
+ args.push(name);
459
+ if (target !== undefined && target !== '')
460
+ args.push(target);
461
+ await git(exec, repoRoot, args);
462
+ const hash = (await git(exec, repoRoot, ['rev-list', '-n', '1', name])).trim();
463
+ return { tag: name, hash };
464
+ }
436
465
  /**
437
466
  * Rename a LOCAL branch (`git branch -m`): branch refs are repository-wide,
438
467
  * so the command runs at the repository root and every worktree HEAD pointing
@@ -545,6 +574,73 @@ export async function pushBranch(exec, cwd) {
545
574
  const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
546
575
  return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
547
576
  }
577
+ /** Every LOCAL tag name of the repository, in git's own sort order. */
578
+ export async function listTagNames(exec, cwd) {
579
+ const text = await git(exec, cwd, ['for-each-ref', 'refs/tags', '--format=%(refname:short)']);
580
+ return text.split('\n').map(line => line.trim()).filter(line => line !== '');
581
+ }
582
+ /**
583
+ * Pick the remote ONE tag push goes to, in fallback order: the checked-out
584
+ * branch's upstream remote, `remote.pushDefault`, then the only remote there
585
+ * is. Ambiguity is reported instead of guessed — a tag pushed to the wrong
586
+ * remote is a published mistake, not an inconvenience.
587
+ * @param exec - executor seam.
588
+ * @param cwd - directory whose checked-out branch supplies the upstream.
589
+ * @returns the remote name, or the caller-side reason none could be chosen.
590
+ */
591
+ export async function resolvePushRemote(exec, cwd) {
592
+ const upstream = (await gitMaybe(exec, cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']))?.trim() ?? '';
593
+ const slash = upstream.indexOf('/');
594
+ if (slash > 0)
595
+ return { ok: true, remote: upstream.slice(0, slash) };
596
+ const pushDefault = (await gitMaybe(exec, cwd, ['config', '--get', 'remote.pushDefault']))?.trim() ?? '';
597
+ if (pushDefault !== '')
598
+ return { ok: true, remote: pushDefault };
599
+ const remotes = ((await gitMaybe(exec, cwd, ['remote'])) ?? '')
600
+ .split('\n').map(line => line.trim()).filter(line => line !== '');
601
+ const only = remotes.length === 1 ? remotes[0] : undefined;
602
+ if (only !== undefined)
603
+ return { ok: true, remote: only };
604
+ return { ok: false, message: remotes.length === 0
605
+ ? 'no remote to push the tag to: the current branch has no upstream and the repository has none either'
606
+ : `multiple remotes (${remotes.join(', ')}) and the current branch has no upstream: set one, or git config remote.pushDefault <name>` };
607
+ }
608
+ /**
609
+ * Push ONE existing local tag to `remote` (`git push <remote>
610
+ * refs/tags/<name>:refs/tags/<name>`) — the publish step a tag needs and a
611
+ * branch push never performs: `git push` sends the current branch alone, so a
612
+ * tag made on an already-pushed commit is invisible to the remote.
613
+ *
614
+ * The tag must already exist locally (the route verifies it against
615
+ * {@link listTagNames}); a remote that already carries the SAME tag object is
616
+ * reported as `pushed: false` without running the push at all. The refspec
617
+ * always names both sides explicitly, so no configured push refspec (a mirror,
618
+ * a `push.default=upstream` branch) can widen what a menu click publishes, and
619
+ * tags are never deleted or overwritten: `--force` is deliberately absent, and
620
+ * a remote tag pointing somewhere else makes git refuse (mapped to 400).
621
+ *
622
+ * The cn-accelerate rewrite is never injected here (no {@link accelArgs}
623
+ * parameter exists, like {@link pushBranch}): a mirror has no `receive-pack` —
624
+ * and the pre-push probe must see the very remote the push targets, or the
625
+ * `pushed` flag would compare two different servers.
626
+ * @param exec - executor seam.
627
+ * @param cwd - any directory inside the repository (tag refs are shared).
628
+ * @param name - an existing local tag name.
629
+ * @param remote - the remote to publish to (see {@link resolvePushRemote}).
630
+ * @returns the tag, the remote, and whether the push had to run.
631
+ */
632
+ export async function pushTag(exec, cwd, name, remote) {
633
+ const ref = `refs/tags/${name}`;
634
+ const local = (await git(exec, cwd, ['rev-parse', '--verify', ref])).trim();
635
+ const listed = await gitMaybe(exec, cwd, ['ls-remote', '--refs', remote, ref], NETWORK_TIMEOUT_MS);
636
+ const present = (listed ?? '').split('\n')
637
+ .map(line => line.trim().split(/\s+/)[0] ?? '')
638
+ .includes(local);
639
+ if (present)
640
+ return { tag: name, remote, pushed: false };
641
+ await git(exec, cwd, ['push', remote, `${ref}:${ref}`], NETWORK_TIMEOUT_MS);
642
+ return { tag: name, remote, pushed: true };
643
+ }
548
644
  /** Guard for route inputs: a non-empty absolute directory path. */
549
645
  export function isAbsoluteDir(value) {
550
646
  return value.trim() !== '' && isAbsolute(value);
@@ -879,13 +975,18 @@ export function parseLogOutput(out) {
879
975
  * `rev-parse --verify` first, so an arbitrary string can never reach the log
880
976
  * arguments as anything but a resolvable rev. An unborn checkout (no commits
881
977
  * yet) answers an empty page instead of an error.
978
+ *
979
+ * `--topo-order` is unconditional: the history card draws lane lines from
980
+ * `parents`, and a pure date order would interleave a branch's commits
981
+ * before their merge base, leaving edges pointing at rows not yet painted.
882
982
  * @param exec - executor seam.
883
983
  * @param cwd - the checkout directory.
884
984
  * @param limit - page size (1..200, validated upstream).
885
985
  * @param skip - offset (>= 0, validated upstream).
886
986
  * @param ref - optional starting rev; absent = the checkout's HEAD.
987
+ * @param filter - optional all-refs/author/date window.
887
988
  */
888
- export async function logHistory(exec, cwd, limit, skip, ref) {
989
+ export async function logHistory(exec, cwd, limit, skip, ref, filter) {
889
990
  let start;
890
991
  if (ref !== undefined && ref !== '' && ref !== 'HEAD') {
891
992
  const verified = await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
@@ -894,10 +995,21 @@ export async function logHistory(exec, cwd, limit, skip, ref) {
894
995
  }
895
996
  start = verified.trim();
896
997
  }
998
+ // A ref narrows the walk to that ref's ancestry; `--all` is only the
999
+ // no-ref default. Combining them would list every branch under a
1000
+ // "this branch" filter.
1001
+ const all = filter?.all === true && start === undefined;
1002
+ const author = filter?.author?.trim() ?? '';
1003
+ const since = filter?.since;
1004
+ const until = filter?.until;
897
1005
  try {
898
1006
  const out = await git(exec, cwd, [
899
- 'log', `--max-count=${String(limit)}`, `--skip=${String(skip)}`,
1007
+ 'log', `--max-count=${String(limit)}`, `--skip=${String(skip)}`, '--topo-order',
900
1008
  '--date=iso-strict', `--pretty=format:${LOG_FORMAT}`,
1009
+ ...(all ? ['--all'] : []),
1010
+ ...(author === '' ? [] : [`--author=${author}`]),
1011
+ ...(since === undefined ? [] : [`--since=${new Date(since).toISOString()}`]),
1012
+ ...(until === undefined ? [] : [`--until=${new Date(until).toISOString()}`]),
901
1013
  ...(start === undefined ? [] : [start]),
902
1014
  ]);
903
1015
  return parseLogOutput(out);
package/lib/index.js CHANGED
@@ -9,9 +9,9 @@ import { homedir } from 'node:os';
9
9
  import z from '@deepseek-ai/schemastery';
10
10
  import { childProcessExec } from './git.js';
11
11
  import { ACCEL_DEFAULT_URL, accelerateUrlError } from './normalize.js';
12
- import { handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
12
+ import { handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateTag, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handlePushTag, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
13
13
  import { validateRootDir } from './settings.js';
14
- import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
14
+ import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_TAG, ROUTE_TAG_PUSH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
15
15
  export const name = 'dsh-git-branch';
16
16
  export const inject = [];
17
17
  /** Largest accepted request body (bytes) — these payloads are a few strings. */
@@ -227,6 +227,8 @@ export function apply(ctx, config = {}) {
227
227
  postRoute(ROUTE_COMMIT, 'commit', handleCommit);
228
228
  postRoute(ROUTE_LOG, 'log', handleLog);
229
229
  postRoute(ROUTE_COMMIT_FILES, 'commit-files', handleCommitFiles);
230
+ postRoute(ROUTE_TAG, 'tag', handleCreateTag);
231
+ postRoute(ROUTE_TAG_PUSH, 'tag-push', handlePushTag);
230
232
  // The AI commit-message route exists only where the host tree provides
231
233
  // the dsh-llm service: the injection resolves once the service is up and
232
234
  // never fires without it, so an llm-less deployment answers 404 and the
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 { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateWorktreeResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, RemoveWorktreeResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
7
+ import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, 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;
@@ -50,7 +50,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
50
50
  /** One route outcome: HTTP status plus the JSON body. */
51
51
  export interface RouteOutcome {
52
52
  status: number;
53
- body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | AiCommitMessageResult | RouteError;
53
+ body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | CreateTagResult | PushTagResult | AiCommitMessageResult | RouteError;
54
54
  }
55
55
  /**
56
56
  * POST /group — git belonging facts for a batch of workspace directories.
@@ -274,10 +274,40 @@ export declare const LOG_LIMIT_MAX = 200;
274
274
  * POST /log — one page of the queried checkout's history. The optional `ref`
275
275
  * is verified server-side (`rev-parse --verify`), an unborn checkout answers
276
276
  * an empty page, and everything else surfaces git's own stderr.
277
+ *
278
+ * `all` walks every ref instead of HEAD (the history card's default view: one
279
+ * graph holding all branches, tags, and their merge edges). `author` and the
280
+ * `since`/`until` window are pushed down to git rather than filtered in the
281
+ * browser — the card paginates, so a client-side filter would only narrow the
282
+ * page it already has and silently hide matches on later pages.
277
283
  * @param deps - host dependencies.
278
- * @param body - parsed request body: `{ repoPath, limit?, skip?, ref? }`.
284
+ * @param body - parsed request body: `{ repoPath, limit?, skip?, ref?, all?, author?, since?, until? }`.
279
285
  */
280
286
  export declare function handleLog(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
287
+ /**
288
+ * POST /tag — create a tag at an existing commit of the queried repository.
289
+ * The target is restricted to a commit hash (the only shape the history card
290
+ * sends), so an arbitrary string can never reach `git tag` as a flag; git
291
+ * validates the tag name itself and a duplicate surfaces as 400.
292
+ * @param deps - host dependencies.
293
+ * @param body - parsed request body: `{ repoPath, name, target?, message? }`.
294
+ */
295
+ export declare function handleCreateTag(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
296
+ /**
297
+ * POST /tag-push — publish ONE existing local tag to a remote. Separate from
298
+ * `POST /push` on purpose: a branch push sends the branch alone, so a tag on
299
+ * an already-pushed commit needs its own verb.
300
+ *
301
+ * The name must match a LOCAL tag this repository actually has (checked
302
+ * against `for-each-ref refs/tags`), so no free string ever reaches git as a
303
+ * flag or a refspec; the remote is resolved server-side and an ambiguous
304
+ * choice is 400. A remote that already carries the same tag object answers
305
+ * `pushed: false` (nothing was sent); a diverged remote tag is git's own
306
+ * refusal, mapped to 400 by {@link gitFailure} — never forced.
307
+ * @param deps - host dependencies.
308
+ * @param body - parsed request body: `{ repoPath, name }`.
309
+ */
310
+ export declare function handlePushTag(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
281
311
  /**
282
312
  * POST /commit-files — the files one commit of the queried checkout changed.
283
313
  * The hash is verified server-side; unknown revisions answer 400.
package/lib/routes.js CHANGED
@@ -5,7 +5,7 @@
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, 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';
8
+ import { appendWorktreeExclude, fsExcludeSeams, GitError, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, createTag, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, listTagNames, logHistory, probeRepo, probeWorkspaceGit, pushTag, removeWorktree, renameBranch, resolveBranch, resolvePushRemote, pushBranch, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
9
9
  import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, sanitizeBranchDir } from './normalize.js';
10
10
  import { resolveRootDir } from './settings.js';
11
11
  import { COMMIT_MESSAGE_LIMIT } from './wire.js';
@@ -1006,15 +1006,26 @@ export async function handleCommit(deps, body) {
1006
1006
  /** Default and maximum page size of POST /log. */
1007
1007
  export const LOG_LIMIT_DEFAULT = 50;
1008
1008
  export const LOG_LIMIT_MAX = 200;
1009
+ /** Bound for the author filter and the tag annotation (characters). */
1010
+ const LOG_AUTHOR_LIMIT = 200;
1011
+ const TAG_MESSAGE_LIMIT = 5000;
1012
+ /** Bound for one tag NAME (characters), shared by create and push. */
1013
+ const TAG_NAME_LIMIT = 300;
1009
1014
  /**
1010
1015
  * POST /log — one page of the queried checkout's history. The optional `ref`
1011
1016
  * is verified server-side (`rev-parse --verify`), an unborn checkout answers
1012
1017
  * an empty page, and everything else surfaces git's own stderr.
1018
+ *
1019
+ * `all` walks every ref instead of HEAD (the history card's default view: one
1020
+ * graph holding all branches, tags, and their merge edges). `author` and the
1021
+ * `since`/`until` window are pushed down to git rather than filtered in the
1022
+ * browser — the card paginates, so a client-side filter would only narrow the
1023
+ * page it already has and silently hide matches on later pages.
1013
1024
  * @param deps - host dependencies.
1014
- * @param body - parsed request body: `{ repoPath, limit?, skip?, ref? }`.
1025
+ * @param body - parsed request body: `{ repoPath, limit?, skip?, ref?, all?, author?, since?, until? }`.
1015
1026
  */
1016
1027
  export async function handleLog(deps, body) {
1017
- const parsed = readBody(body, ['repoPath'], [], ['ref'], ['limit', 'skip']);
1028
+ const parsed = readBody(body, ['repoPath'], ['all'], ['ref', 'author'], ['limit', 'skip', 'since', 'until']);
1018
1029
  if (isOutcome(parsed))
1019
1030
  return parsed;
1020
1031
  const repoPath = readRepoPath(parsed);
@@ -1031,14 +1042,130 @@ export async function handleLog(deps, body) {
1031
1042
  if (ref !== undefined && (ref.trim() === '' || ref.length > 300 || ref.startsWith('-'))) {
1032
1043
  return fail(400, '"ref" must be a non-empty revision name');
1033
1044
  }
1045
+ const author = parsed.author;
1046
+ if (author !== undefined && (author.trim() === '' || author.length > LOG_AUTHOR_LIMIT)) {
1047
+ return fail(400, `"author" must be a non-empty string of at most ${String(LOG_AUTHOR_LIMIT)} characters`);
1048
+ }
1049
+ const since = parsed.since;
1050
+ const until = parsed.until;
1051
+ for (const [key, value] of [['since', since], ['until', until]]) {
1052
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
1053
+ return fail(400, `"${key}" must be a positive epoch-millisecond timestamp`);
1054
+ }
1055
+ }
1056
+ if (since !== undefined && until !== undefined && until < since) {
1057
+ return fail(400, '"until" must not be earlier than "since"');
1058
+ }
1034
1059
  try {
1035
- const commits = await logHistory(deps.exec, repoPath, limit, skip, ref);
1060
+ const commits = await logHistory(deps.exec, repoPath, limit, skip, ref, {
1061
+ all: parsed.all === true,
1062
+ ...(author === undefined ? {} : { author }),
1063
+ ...(since === undefined ? {} : { since }),
1064
+ ...(until === undefined ? {} : { until }),
1065
+ });
1036
1066
  return { status: 200, body: { commits } };
1037
1067
  }
1038
1068
  catch (error) {
1039
1069
  return checkoutFailure(error);
1040
1070
  }
1041
1071
  }
1072
+ /**
1073
+ * POST /tag — create a tag at an existing commit of the queried repository.
1074
+ * The target is restricted to a commit hash (the only shape the history card
1075
+ * sends), so an arbitrary string can never reach `git tag` as a flag; git
1076
+ * validates the tag name itself and a duplicate surfaces as 400.
1077
+ * @param deps - host dependencies.
1078
+ * @param body - parsed request body: `{ repoPath, name, target?, message? }`.
1079
+ */
1080
+ export async function handleCreateTag(deps, body) {
1081
+ const parsed = readBody(body, ['repoPath', 'name'], [], ['target', 'message']);
1082
+ if (isOutcome(parsed))
1083
+ return parsed;
1084
+ const { repoPath, name, target, message } = parsed;
1085
+ if (!isAbsoluteDir(repoPath))
1086
+ return fail(400, '"repoPath" must be an absolute directory');
1087
+ if (name.trim() === '')
1088
+ return fail(400, '"name" must be non-empty');
1089
+ // A leading dash would ride `git tag` as a flag — reject before the exec.
1090
+ if (name.startsWith('-'))
1091
+ return fail(400, '"name" must not start with "-"');
1092
+ if (name.length > TAG_NAME_LIMIT)
1093
+ return fail(400, `"name" must be at most ${String(TAG_NAME_LIMIT)} characters`);
1094
+ // The target lands on `git tag <name> <target>` verbatim: restrict it to the
1095
+ // object id shape the history card sends rather than trusting a free string.
1096
+ if (target !== undefined && !/^[0-9a-fA-F]{4,64}$/.test(target)) {
1097
+ return fail(400, '"target" must be a git object id');
1098
+ }
1099
+ if (message !== undefined && message.length > TAG_MESSAGE_LIMIT) {
1100
+ return fail(400, `"message" must be at most ${String(TAG_MESSAGE_LIMIT)} characters`);
1101
+ }
1102
+ try {
1103
+ const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
1104
+ if (facts === undefined)
1105
+ return fail(400, `"${repoPath}" is not inside a git repository`);
1106
+ // Tag refs are repository-wide metadata and touch no checkout, so the
1107
+ // command runs at the repository root — the same scope as a branch rename.
1108
+ const result = await createTag(deps.exec, facts.repoRoot, name, target, message);
1109
+ const body = result;
1110
+ return { status: 200, body };
1111
+ }
1112
+ catch (error) {
1113
+ if (error instanceof GitError)
1114
+ return gitFailure(error);
1115
+ return fail(500, error instanceof Error ? error.message : String(error));
1116
+ }
1117
+ }
1118
+ /**
1119
+ * POST /tag-push — publish ONE existing local tag to a remote. Separate from
1120
+ * `POST /push` on purpose: a branch push sends the branch alone, so a tag on
1121
+ * an already-pushed commit needs its own verb.
1122
+ *
1123
+ * The name must match a LOCAL tag this repository actually has (checked
1124
+ * against `for-each-ref refs/tags`), so no free string ever reaches git as a
1125
+ * flag or a refspec; the remote is resolved server-side and an ambiguous
1126
+ * choice is 400. A remote that already carries the same tag object answers
1127
+ * `pushed: false` (nothing was sent); a diverged remote tag is git's own
1128
+ * refusal, mapped to 400 by {@link gitFailure} — never forced.
1129
+ * @param deps - host dependencies.
1130
+ * @param body - parsed request body: `{ repoPath, name }`.
1131
+ */
1132
+ export async function handlePushTag(deps, body) {
1133
+ const parsed = readBody(body, ['repoPath', 'name']);
1134
+ if (isOutcome(parsed))
1135
+ return parsed;
1136
+ const { repoPath, name } = parsed;
1137
+ if (!isAbsoluteDir(repoPath))
1138
+ return fail(400, '"repoPath" must be an absolute directory');
1139
+ if (name.trim() === '')
1140
+ return fail(400, '"name" must be non-empty');
1141
+ // A leading dash would ride `git push` as a flag — reject before the exec.
1142
+ if (name.startsWith('-'))
1143
+ return fail(400, '"name" must not start with "-"');
1144
+ if (name.length > TAG_NAME_LIMIT)
1145
+ return fail(400, `"name" must be at most ${String(TAG_NAME_LIMIT)} characters`);
1146
+ try {
1147
+ const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
1148
+ if (facts === undefined)
1149
+ return fail(400, `"${repoPath}" is not inside a git repository`);
1150
+ // The push runs at the queried directory's own toplevel, so the remote is
1151
+ // the one ITS checked-out branch tracks (a linked worktree publishes the
1152
+ // remote its own branch knows); tags themselves are repository-wide.
1153
+ const tags = await listTagNames(deps.exec, facts.toplevel);
1154
+ if (!tags.includes(name))
1155
+ return fail(400, `"${name}" is not a local tag`);
1156
+ const choice = await resolvePushRemote(deps.exec, facts.toplevel);
1157
+ if (!choice.ok)
1158
+ return fail(400, choice.message);
1159
+ const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote);
1160
+ const body = result;
1161
+ return { status: 200, body };
1162
+ }
1163
+ catch (error) {
1164
+ if (error instanceof GitError)
1165
+ return gitFailure(error);
1166
+ return fail(500, error instanceof Error ? error.message : String(error));
1167
+ }
1168
+ }
1042
1169
  /**
1043
1170
  * POST /commit-files — the files one commit of the queried checkout changed.
1044
1171
  * The hash is verified server-side; unknown revisions answer 400.
package/lib/wire.d.ts CHANGED
@@ -53,6 +53,10 @@ export declare const ROUTE_COMMIT = "/plugin/git-worktree/commit";
53
53
  export declare const ROUTE_LOG = "/plugin/git-worktree/log";
54
54
  /** POST ROUTE_PREFIX/commit-files — the files one commit changed. */
55
55
  export declare const ROUTE_COMMIT_FILES = "/plugin/git-worktree/commit-files";
56
+ /** POST ROUTE_PREFIX/tag — create a tag at an existing commit. */
57
+ export declare const ROUTE_TAG = "/plugin/git-worktree/tag";
58
+ /** POST ROUTE_PREFIX/tag-push — push ONE existing local tag to its remote. */
59
+ export declare const ROUTE_TAG_PUSH = "/plugin/git-worktree/tag-push";
56
60
  /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
57
61
  * the staged diff (mounted only where the host provides the llm service). */
58
62
  export declare const ROUTE_AI_COMMIT_MESSAGE = "/plugin/git-worktree/ai-commit-message";
@@ -472,11 +476,57 @@ export interface LogBody {
472
476
  skip?: number;
473
477
  /** Optional starting ref (a branch name, `HEAD`, …); validated server-side. */
474
478
  ref?: string;
479
+ /** Walk every ref (branches + tags) instead of the checkout's HEAD — the
480
+ * history card's default view, which is what makes branches/merges visible.
481
+ * Ignored when `ref` is given. */
482
+ all?: boolean;
483
+ /** Committer-author substring/regex (`--author`); absent = every author. */
484
+ author?: string;
485
+ /** Lower bound on the commit date, epoch milliseconds (`--since`). */
486
+ since?: number;
487
+ /** Upper bound on the commit date, epoch milliseconds (`--until`). */
488
+ until?: number;
475
489
  }
476
490
  /** POST log response body. */
477
491
  export interface LogResult {
478
492
  commits: CommitEntry[];
479
493
  }
494
+ /** POST tag request body. */
495
+ export interface CreateTagBody {
496
+ /** Any directory inside the repository (tag refs are repository-wide). */
497
+ repoPath: string;
498
+ /** User-typed tag name, verbatim (git validates it). */
499
+ name: string;
500
+ /** The commit to tag (a full or abbreviated hash, validated server-side);
501
+ * absent tags the repository's current HEAD. */
502
+ target?: string;
503
+ /** Optional annotation message; non-empty creates an ANNOTATED tag. */
504
+ message?: string;
505
+ }
506
+ /** POST tag response body. */
507
+ export interface CreateTagResult {
508
+ /** The tag created. */
509
+ tag: string;
510
+ /** The commit hash the tag points at. */
511
+ hash: string;
512
+ }
513
+ /** POST tag-push request body. */
514
+ export interface PushTagBody {
515
+ /** Any directory inside the repository (tag refs are repository-wide). */
516
+ repoPath: string;
517
+ /** An EXISTING local tag name, verified server-side before any push. */
518
+ name: string;
519
+ }
520
+ /** POST tag-push response body. */
521
+ export interface PushTagResult {
522
+ /** The tag that was the subject of the push. */
523
+ tag: string;
524
+ /** The remote the tag was pushed to (resolved server-side). */
525
+ remote: string;
526
+ /** False when the remote already carried this exact tag object — nothing
527
+ * was sent. True once the push command actually ran. */
528
+ pushed: boolean;
529
+ }
480
530
  /** One file row of POST commit-files. */
481
531
  export interface CommitFileEntry {
482
532
  /** New path, relative to the repository root. */
package/lib/wire.js CHANGED
@@ -53,6 +53,10 @@ export const ROUTE_COMMIT = `${ROUTE_PREFIX}/commit`;
53
53
  export const ROUTE_LOG = `${ROUTE_PREFIX}/log`;
54
54
  /** POST ROUTE_PREFIX/commit-files — the files one commit changed. */
55
55
  export const ROUTE_COMMIT_FILES = `${ROUTE_PREFIX}/commit-files`;
56
+ /** POST ROUTE_PREFIX/tag — create a tag at an existing commit. */
57
+ export const ROUTE_TAG = `${ROUTE_PREFIX}/tag`;
58
+ /** POST ROUTE_PREFIX/tag-push — push ONE existing local tag to its remote. */
59
+ export const ROUTE_TAG_PUSH = `${ROUTE_PREFIX}/tag-push`;
56
60
  /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
57
61
  * the staged diff (mounted only where the host provides the llm service). */
58
62
  export const ROUTE_AI_COMMIT_MESSAGE = `${ROUTE_PREFIX}/ai-commit-message`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-branch-manage",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",