dsh-git-branch-manage 1.0.6 → 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/client.js CHANGED
@@ -129,6 +129,8 @@ window.__ModuleLoader__.load({
129
129
  const ROUTE_COMMIT_FILES = `${ROUTE_PREFIX}/commit-files`;
130
130
  /** POST ROUTE_PREFIX/tag — create a tag at an existing commit. */
131
131
  const ROUTE_TAG = `${ROUTE_PREFIX}/tag`;
132
+ /** POST ROUTE_PREFIX/tag-push — push ONE existing local tag to its remote. */
133
+ const ROUTE_TAG_PUSH = `${ROUTE_PREFIX}/tag-push`;
132
134
  /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
133
135
  * the staged diff (mounted only where the host provides the llm service). */
134
136
  const ROUTE_AI_COMMIT_MESSAGE = `${ROUTE_PREFIX}/ai-commit-message`;
@@ -433,6 +435,18 @@ window.__ModuleLoader__.load({
433
435
  });
434
436
  }
435
437
  /**
438
+ * Push ONE local tag to its remote (the host resolves which remote). Unlike
439
+ * {@link requestPush}, which sends the current branch alone.
440
+ * @param repoPath - absolute directory inside the repository.
441
+ * @param name - an existing local tag name (verified host-side).
442
+ */
443
+ function requestPushTag(repoPath, name) {
444
+ return post(ROUTE_TAG_PUSH, {
445
+ repoPath,
446
+ name
447
+ });
448
+ }
449
+ /**
436
450
  * Generate a commit message from the checkout's diff with the requesting
437
451
  * session's own model (a one-shot host llm call).
438
452
  * @param body - the repo path plus the session's model selection and an
@@ -3635,6 +3649,14 @@ window.__ModuleLoader__.load({
3635
3649
  return badges;
3636
3650
  }
3637
3651
  /**
3652
+ * The TAG names one commit's decoration carries, in decoration order — the
3653
+ * per-tag publish targets the commit menu offers. Built on
3654
+ * {@link classifyRefs} so "what counts as a tag" has exactly one definition.
3655
+ */
3656
+ function commitTags(refs) {
3657
+ return classifyRefs(refs).filter((badge) => badge.kind === "tag").map((badge) => badge.text);
3658
+ }
3659
+ /**
3638
3660
  * Split the working-tree rows into the two panel groups. Untracked files
3639
3661
  * render in the unstaged group as `?` (git add still stages them); an
3640
3662
  * unmerged conflict renders in the unstaged group as `U` unchecked (git add
@@ -4772,8 +4794,11 @@ window.__ModuleLoader__.load({
4772
4794
  * author, and the absolute commit time. CLICKING a row expands the commit's
4773
4795
  * changed files INLINE below it (exactly one open at a time), loading them on
4774
4796
  * first open; RIGHT-CLICKING it opens the commit menu (copy hash, copy
4775
- * subject, new branch here, tag here), which is where the history card's two
4776
- * writing verbs live.
4797
+ * subject, new branch here, tag here, and — for every tag the commit already
4798
+ * carries — push that tag), which is where the history card's writing verbs
4799
+ * live. Pushing a tag is offered per EXISTING tag rather than folded into the
4800
+ * branch push: `git push` sends the branch alone, so a tag on an
4801
+ * already-pushed commit would otherwise have no way out of the local repo.
4777
4802
  *
4778
4803
  * The head carries the three filters the card asks for, in this order —
4779
4804
  * author, branch (a ref to pin the walk on, or every ref), and a time window
@@ -4941,7 +4966,7 @@ window.__ModuleLoader__.load({
4941
4966
  return `${commit.subject} · ${commit.authorName} <${commit.authorEmail}> · ${absolute} · ${commit.short}`;
4942
4967
  }
4943
4968
  function HistorySection(props) {
4944
- const { t, commits, loaded, loadError, loading, hasMore, loadingMore, expanded, filesByHash, filters, branches, busy, onLoadMore, onToggle, onFilters, onCreateBranch, onCreateTag, pushToast } = props;
4969
+ const { t, commits, loaded, loadError, loading, hasMore, loadingMore, expanded, filesByHash, filters, branches, busy, onLoadMore, onToggle, onFilters, onCreateBranch, onCreateTag, onPushTag, pushToast } = props;
4945
4970
  const [menu, setMenu] = (0, react.useState)(null);
4946
4971
  const [dialog, setDialog] = (0, react.useState)(null);
4947
4972
  const [draft, setDraft] = (0, react.useState)("");
@@ -5103,6 +5128,8 @@ window.__ModuleLoader__.load({
5103
5128
  })
5104
5129
  }, commit.hash));
5105
5130
  const dialogTitle = dialog === null ? "" : dialog.kind === "branch" ? t("createFlyAskFrom", { branch: dialog.commit.short }) : t("panel.tagTitle");
5131
+ /** Tags the right-clicked commit carries: one publish item each. */
5132
+ const menuTags = menu === null ? [] : commitTags(menu.commit.refs);
5106
5133
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
5107
5134
  className: `${GitHistoryPanel_module_default.section} ${GitHistoryPanel_module_default.sectionFill} ${GitHistoryPanel_module_default.sectionHistory}`,
5108
5135
  "aria-label": t("panel.history"),
@@ -5177,7 +5204,18 @@ window.__ModuleLoader__.load({
5177
5204
  disabled: busy,
5178
5205
  onClick: () => openDialog("tag", menu.commit),
5179
5206
  children: t("panel.branchTag")
5180
- })
5207
+ }),
5208
+ menuTags.map((tag) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5209
+ type: "button",
5210
+ className: GitHistoryPanel_module_default.menuItem,
5211
+ role: "menuitem",
5212
+ disabled: busy,
5213
+ onClick: () => {
5214
+ setMenu(null);
5215
+ onPushTag(tag);
5216
+ },
5217
+ children: t("panel.tagPush", { tag })
5218
+ }, tag))
5181
5219
  ]
5182
5220
  }) : null,
5183
5221
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
@@ -5871,6 +5909,28 @@ window.__ModuleLoader__.load({
5871
5909
  runGuarded,
5872
5910
  t
5873
5911
  ]);
5912
+ /** Publish ONE local tag. Its own verb, apart from the branch push: `git
5913
+ * push` sends the branch alone, so a tag made on an already-pushed commit
5914
+ * would otherwise never reach the remote. Deliberately no refetch: tags are
5915
+ * local refs and a remote tag changes nothing the panel can see. */
5916
+ const onPushTag = (0, react.useCallback)((name) => {
5917
+ runGuarded(async () => {
5918
+ const result = await requestPushTag(cwd, name);
5919
+ if (!result.ok) throw new Error(result.error);
5920
+ pushToast(result.pushed ? t("panel.tagPushDone", {
5921
+ tag: result.tag,
5922
+ remote: result.remote
5923
+ }) : t("panel.tagPushUpToDate", {
5924
+ tag: result.tag,
5925
+ remote: result.remote
5926
+ }), "success");
5927
+ });
5928
+ }, [
5929
+ cwd,
5930
+ pushToast,
5931
+ runGuarded,
5932
+ t
5933
+ ]);
5874
5934
  const onToggleCommit = (0, react.useCallback)((hash) => {
5875
5935
  if (expanded === hash) {
5876
5936
  setExpanded(null);
@@ -6001,6 +6061,7 @@ window.__ModuleLoader__.load({
6001
6061
  onFilters: onHistoryFilters,
6002
6062
  onCreateBranch: onCreate,
6003
6063
  onCreateTag,
6064
+ onPushTag,
6004
6065
  pushToast
6005
6066
  })
6006
6067
  ]
@@ -7389,6 +7450,9 @@ window.__ModuleLoader__.load({
7389
7450
  "panel.tagName": "Tag name",
7390
7451
  "panel.tagMessage": "Message (optional)",
7391
7452
  "panel.tagDone": "Tagged {tag}",
7453
+ "panel.tagPush": "Push tag {tag}",
7454
+ "panel.tagPushDone": "Pushed tag {tag} to {remote}",
7455
+ "panel.tagPushUpToDate": "Tag {tag} is already on {remote}",
7392
7456
  "panel.noCommits": "This repository has no commits yet",
7393
7457
  "panel.noMatches": "No matching commits",
7394
7458
  "panel.loadMore": "Load more",
@@ -7607,6 +7671,9 @@ window.__ModuleLoader__.load({
7607
7671
  "panel.tagName": "标签名称",
7608
7672
  "panel.tagMessage": "消息(可选)",
7609
7673
  "panel.tagDone": "已标记 {tag}",
7674
+ "panel.tagPush": "推送标签 {tag}",
7675
+ "panel.tagPushDone": "已推送标签 {tag} 到 {remote}",
7676
+ "panel.tagPushUpToDate": "标签 {tag} 已存在于 {remote}",
7610
7677
  "panel.noCommits": "仓库还没有提交",
7611
7678
  "panel.noMatches": "没有匹配的提交",
7612
7679
  "panel.loadMore": "加载更多",
package/lib/git.d.ts CHANGED
@@ -301,6 +301,57 @@ export interface PushOutcome {
301
301
  * push-specific diagnostic is preserved; in that case `pushed` is false.
302
302
  */
303
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>;
304
355
  /** Guard for route inputs: a non-empty absolute directory path. */
305
356
  export declare function isAbsoluteDir(value: string): boolean;
306
357
  /** Pre-delete facts of one worktree directory, for the remove-confirm dialog. */
package/lib/git.js CHANGED
@@ -574,6 +574,73 @@ export async function pushBranch(exec, cwd) {
574
574
  const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
575
575
  return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
576
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
+ }
577
644
  /** Guard for route inputs: a non-empty absolute directory path. */
578
645
  export function isAbsoluteDir(value) {
579
646
  return value.trim() !== '' && isAbsolute(value);
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, handleCreateTag, 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_TAG, 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. */
@@ -228,6 +228,7 @@ export function apply(ctx, config = {}) {
228
228
  postRoute(ROUTE_LOG, 'log', handleLog);
229
229
  postRoute(ROUTE_COMMIT_FILES, 'commit-files', handleCommitFiles);
230
230
  postRoute(ROUTE_TAG, 'tag', handleCreateTag);
231
+ postRoute(ROUTE_TAG_PUSH, 'tag-push', handlePushTag);
231
232
  // The AI commit-message route exists only where the host tree provides
232
233
  // the dsh-llm service: the injection resolves once the service is up and
233
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, CreateTagResult, 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 | CreateTagResult | 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.
@@ -293,6 +293,21 @@ export declare function handleLog(deps: RouteDeps, body: unknown): Promise<Route
293
293
  * @param body - parsed request body: `{ repoPath, name, target?, message? }`.
294
294
  */
295
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>;
296
311
  /**
297
312
  * POST /commit-files — the files one commit of the queried checkout changed.
298
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, createTag, 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';
@@ -1009,6 +1009,8 @@ export const LOG_LIMIT_MAX = 200;
1009
1009
  /** Bound for the author filter and the tag annotation (characters). */
1010
1010
  const LOG_AUTHOR_LIMIT = 200;
1011
1011
  const TAG_MESSAGE_LIMIT = 5000;
1012
+ /** Bound for one tag NAME (characters), shared by create and push. */
1013
+ const TAG_NAME_LIMIT = 300;
1012
1014
  /**
1013
1015
  * POST /log — one page of the queried checkout's history. The optional `ref`
1014
1016
  * is verified server-side (`rev-parse --verify`), an unborn checkout answers
@@ -1087,8 +1089,8 @@ export async function handleCreateTag(deps, body) {
1087
1089
  // A leading dash would ride `git tag` as a flag — reject before the exec.
1088
1090
  if (name.startsWith('-'))
1089
1091
  return fail(400, '"name" must not start with "-"');
1090
- if (name.length > 300)
1091
- return fail(400, '"name" must be at most 300 characters');
1092
+ if (name.length > TAG_NAME_LIMIT)
1093
+ return fail(400, `"name" must be at most ${String(TAG_NAME_LIMIT)} characters`);
1092
1094
  // The target lands on `git tag <name> <target>` verbatim: restrict it to the
1093
1095
  // object id shape the history card sends rather than trusting a free string.
1094
1096
  if (target !== undefined && !/^[0-9a-fA-F]{4,64}$/.test(target)) {
@@ -1113,6 +1115,57 @@ export async function handleCreateTag(deps, body) {
1113
1115
  return fail(500, error instanceof Error ? error.message : String(error));
1114
1116
  }
1115
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
+ }
1116
1169
  /**
1117
1170
  * POST /commit-files — the files one commit of the queried checkout changed.
1118
1171
  * The hash is verified server-side; unknown revisions answer 400.
package/lib/wire.d.ts CHANGED
@@ -55,6 +55,8 @@ export declare const ROUTE_LOG = "/plugin/git-worktree/log";
55
55
  export declare const ROUTE_COMMIT_FILES = "/plugin/git-worktree/commit-files";
56
56
  /** POST ROUTE_PREFIX/tag — create a tag at an existing commit. */
57
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";
58
60
  /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
59
61
  * the staged diff (mounted only where the host provides the llm service). */
60
62
  export declare const ROUTE_AI_COMMIT_MESSAGE = "/plugin/git-worktree/ai-commit-message";
@@ -508,6 +510,23 @@ export interface CreateTagResult {
508
510
  /** The commit hash the tag points at. */
509
511
  hash: string;
510
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
+ }
511
530
  /** One file row of POST commit-files. */
512
531
  export interface CommitFileEntry {
513
532
  /** New path, relative to the repository root. */
package/lib/wire.js CHANGED
@@ -55,6 +55,8 @@ export const ROUTE_LOG = `${ROUTE_PREFIX}/log`;
55
55
  export const ROUTE_COMMIT_FILES = `${ROUTE_PREFIX}/commit-files`;
56
56
  /** POST ROUTE_PREFIX/tag — create a tag at an existing commit. */
57
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`;
58
60
  /** POST ROUTE_PREFIX/ai-commit-message — one-shot LLM commit message from
59
61
  * the staged diff (mounted only where the host provides the llm service). */
60
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.6",
3
+ "version": "1.0.7",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",