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 +71 -4
- package/lib/git.d.ts +51 -0
- package/lib/git.js +67 -0
- package/lib/index.js +3 -2
- package/lib/routes.d.ts +17 -2
- package/lib/routes.js +56 -3
- package/lib/wire.d.ts +19 -0
- package/lib/wire.js +2 -0
- package/package.json +1 -1
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
|
|
4776
|
-
* writing verbs
|
|
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 >
|
|
1091
|
-
return fail(400,
|
|
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