@feng0531/dsh-vcs 0.5.1 → 0.7.0
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/README.md +15 -5
- package/branch-switch-host.mjs +35 -0
- package/branch-switch.mjs +193 -0
- package/dist/client.js +2 -3
- package/dist/editor.js +1 -1
- package/dist/frontend.json +1 -1
- package/dist/interface.js +2 -3
- package/dist/svn-host.mjs +1 -1
- package/index.mjs +34 -7
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -2
- package/src/content-search.mjs +74 -0
- package/src/historical-access.mjs +38 -4
- package/vcs.mjs +12 -25
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# DSH VCS
|
|
2
2
|
|
|
3
|
-
在 DeepSeek Harness(DSH)Web 中查看 Git / SVN
|
|
3
|
+
在 DeepSeek Harness(DSH)Web 中查看 Git / SVN 文件变更和提交历史,**默认只读预览**。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
你可以查看差异、浏览历史、追溯文件。0.6.0 起新增一个明确的写操作例外:预览并确认后切换已有本地 Git 分支,会改变 HEAD、暂存区和工作文件;不提供正文编辑、暂存、提交、拉取或推送。源码能力说明不代表目标版本已通过正式发布门禁,请核对对应发布渠道。
|
|
6
6
|
|
|
7
7
|
- npm 包:`@feng0531/dsh-vcs`
|
|
8
8
|
- [下载正式版本](https://github.com/f-e-n-g-0531/dsh-vcs/releases/latest) · [查看更新](https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/CHANGELOG.md)
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
| 浏览 Git 历史 | 搜索提交说明、作者、路径与提交时间范围(since/until,含边界,按 committer date);查看本地分支、提交图、提交详情与目录 |
|
|
18
18
|
| 比较历史版本 | 比较提交与选定父提交,或选择两个本地提交进行 A/B 比较 |
|
|
19
19
|
| 追溯代码来源 | 文件历史、唯一来源重命名追踪(含改名同时修改)、Blame(逐行查看最后修改者和提交) |
|
|
20
|
-
| 搜索与定位 |
|
|
20
|
+
| 搜索与定位 | 在固定提交中分页搜索内容,点击结果定位命中行;高级、基础与分段阅读支持目标行,返回保留结果与阅读设置 |
|
|
21
21
|
| 查看其他内容 | 支持部分静态图片比较;较大历史 UTF-8 文件可分段阅读 |
|
|
22
22
|
| 浏览 SVN 历史 | 明确同意连接后,查看 HTTPS 仓库日志、修订差异和文件历史 |
|
|
23
|
+
| 切换本地 Git 分支(0.6.0 起) | 独立入口预览当前/目标分支与提交,明确确认后执行;不是历史引用导航 |
|
|
23
24
|
|
|
24
25
|
提供中英文界面。编辑器加载失败时会显示基础文本比较;它不是完整的 Monaco 替代品。
|
|
25
26
|
|
|
@@ -74,6 +75,14 @@ VCS 顶部“更新插件”提供更新检查、宿主插件管理器与最新
|
|
|
74
75
|
|
|
75
76
|
新增文件可能使用空白左侧。冲突文件只供查看,不提供三方合并或冲突解决。
|
|
76
77
|
|
|
78
|
+
### 确认式本地 Git 分支切换(0.6.0 候选)
|
|
79
|
+
|
|
80
|
+
历史页的分支下拉只改变阅读起点,**不会切换工作区**。实际切换使用独立的“切换本地分支”入口:先读取已有本地分支,选择目标并预览当前/目标完整引用及提交,再明确确认。预览约 60 秒失效,确认仅能使用一次;取消预览不执行切换,切换过程中不能重复提交。
|
|
81
|
+
|
|
82
|
+
仅支持已有的本地 `refs/heads` 分支和干净、普通的 Git 工作副本。已暂存、未暂存、未跟踪以及**被忽略的文件**都会阻止操作;不会自动保存、stash、强制覆盖、创建分支或连接远端。配置了过滤器或命令 hooks、存在 Git attributes、稀疏/部分克隆、assume-unchanged/skip-worktree、符号链接/子模块、未完成的合并/变基等操作或锁时也会保守拒绝。Git 元数据或工作路径硬链接(含本地克隆共享的对象文件)、元数据符号链接/alternates、linked worktree、分离/符号链接的 Git 元数据目录、`core.worktree`、detached/未创建首个提交的 HEAD,以及与目标分支同名的 tag 均不支持;目标被其他 worktree 占用时不绕过 Git 的拒绝。
|
|
83
|
+
|
|
84
|
+
确认前重新检查仓库和分支状态,但不保证与其他 Git 工具之间的原子隔离。写入已开始后取消、超时或失败可能表示**结果未知**,不能当作已回滚;先手动刷新并核对当前分支、暂存区和工作文件,不要盲目重试。成功后界面重新发现并刷新仓库。
|
|
85
|
+
|
|
77
86
|
### SVN 历史需要单独同意
|
|
78
87
|
|
|
79
88
|
本地 SVN 变更比较不连接服务器。打开远程历史前,需要审阅服务器与路径范围并勾选同意;授权约 5 分钟后过期,须重新手动同意。
|
|
@@ -82,9 +91,10 @@ VCS 顶部“更新插件”提供更新检查、宿主插件管理器与最新
|
|
|
82
91
|
|
|
83
92
|
## 目前的限制
|
|
84
93
|
|
|
85
|
-
- **不是完整的 Fork
|
|
94
|
+
- **不是完整的 Fork 客户端**:除上述确认式已有本地 Git 分支切换外,仓库浏览保持只读;不提供 stage、commit、stash、force、fetch 或 push。历史图和列表都有容量限制;历史分支导航可使用本地分支、标签及本地缓存的远程跟踪引用,不连接远端。
|
|
86
95
|
- 普通文本比较每侧最多 **2MiB**。Git提交/A-B/工作区三个模式比较超限时可手动加载完整文本(UTF-8/GBK/带BOM的UTF-16),每侧最多 **8MiB、10万行**,单行最多65536字符;计算超时会明确提示结果不完整。工作区读取期间发生变化会拒绝结果,但不是系统级原子快照;SVN大文件仍不支持。
|
|
87
|
-
-
|
|
96
|
+
- 内容搜索每页最多100条匹配,扫描最多64个文件/16MiB正文,匹配列表最多1MiB;固定提交树上限10万项/16MiB。超过16MiB的文件跳过并提示结果不完整,二进制排除;无匹配的扫描页仍可能有下一页。长命中行仅显示开头片段,打开文件可继续阅读。
|
|
97
|
+
- 历史 UTF-8 正文分段最多 **16MiB**,每片64KiB;按全局行号定位,行片段有明确提示;基础单版本阅读每窗口最多500行。分段阅读不是完整 Diff。正文按固定对象缓存,最多 32MiB/8 项/60 秒,缓存命中仍重新验证请求授权。历史目录按需分页,每页最多 100 项,单目录仍有 10 万项和 16MiB 命令输出上限。
|
|
88
98
|
- 重命名追溯仅沿第一父链,至少50%相似且确认唯一删除源/新增目标;合并、复制、歧义或累计32次确认后停止。
|
|
89
99
|
- 子模块变更只显示 Index 记录的提交指针与本地检出状态(已检出/未初始化/目录缺失/不可读);不进入子模块、不读取其 HEAD,也不更新或同步。
|
|
90
100
|
- 树浏览和来源行预览可手动查看该历史路径的文件历史,并可选跟随重命名;路径必须存在于所选提交,跟随规则与路径历史一致(第一父链、≥50%相似、唯一确认、32次上限)。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Server-owned, single-use confirmation and cooperative repository exclusion.
|
|
2
|
+
import {randomBytes} from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const access=new Map();
|
|
5
|
+
const error=(code,message)=>Object.assign(new Error(message),{code});
|
|
6
|
+
const keyOf=root=>{const key=path.resolve(root);return process.platform==='win32'?key.toLowerCase():key;};
|
|
7
|
+
export function acquireRepositoryAccess(root,write=false){
|
|
8
|
+
const key=keyOf(root),state=access.get(key)||{readers:0,writer:false};
|
|
9
|
+
if(state.writer||(write&&state.readers))throw error('vcs/busy','This repository has an active operation. Wait for it to finish and prepare again.');
|
|
10
|
+
if(write)state.writer=true;else state.readers++;access.set(key,state);let released=false;
|
|
11
|
+
return()=>{if(released)return;released=true;if(write)state.writer=false;else state.readers--;if(!state.writer&&!state.readers)access.delete(key);};
|
|
12
|
+
}
|
|
13
|
+
export function createBranchSwitchCoordinator({api,now=Date.now,ttlMs=60000,maxTokens=32}){
|
|
14
|
+
const tokens=new Map();
|
|
15
|
+
const purge=()=>{for(const [token,row]of tokens)if(row.expiresAt<=now())tokens.delete(token);};
|
|
16
|
+
return {
|
|
17
|
+
invalidate(entry){for(const [token,row]of tokens)if(row.entry===entry)tokens.delete(token);},
|
|
18
|
+
clear(){tokens.clear();},
|
|
19
|
+
async prepare(address,{entry,repository,cwd,signal,assertAuthorized}){
|
|
20
|
+
const snapshot=await api.prepareLocalBranchSwitch({...repository},{branch:address.branch,signal});await assertAuthorized();signal?.throwIfAborted();purge();
|
|
21
|
+
while(tokens.size>=maxTokens)tokens.delete(tokens.keys().next().value);
|
|
22
|
+
const token=randomBytes(32).toString('hex'),expiresAt=now()+ttlMs;
|
|
23
|
+
tokens.set(token,{snapshot,expiresAt,entry,repository,cwd,sessionId:address.sessionId,repositoryId:address.repositoryId});
|
|
24
|
+
return {token,expiresAt,current:snapshot.current,target:snapshot.target};
|
|
25
|
+
},
|
|
26
|
+
async confirm(address,{entry,repository,cwd,signal,assertAuthorized}){
|
|
27
|
+
purge();const row=tokens.get(address.token);tokens.delete(address.token);
|
|
28
|
+
if(!row||row.entry!==entry||row.repository!==repository||row.cwd!==cwd||row.sessionId!==address.sessionId||row.repositoryId!==address.repositoryId)throw error('vcs/branch-confirmation-invalid','Confirmation is expired, used, or belongs to a different Session/repository. Prepare again.');
|
|
29
|
+
const release=acquireRepositoryAccess(repository.root,true);let started=false;
|
|
30
|
+
try{await assertAuthorized();signal?.throwIfAborted();const result=await api.switchLocalBranch({...repository},{snapshot:row.snapshot,signal,assertAuthorized,onStart(){started=true;}});await assertAuthorized();signal?.throwIfAborted();return result;}
|
|
31
|
+
catch(failure){if(started)throw error('vcs/branch-outcome-unknown','Branch switching started but its final result could not be confirmed. Refresh the repository and inspect the current branch before preparing another switch.');throw failure;}
|
|
32
|
+
finally{release();}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {git} from './local-command.mjs';
|
|
6
|
+
import {currentOperationBudget} from './src/operation-budget.mjs';
|
|
7
|
+
import {isObjectId} from './git-history.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const MAX_BRANCHES = 1000;
|
|
11
|
+
const LF = String.fromCharCode(10), NUL = String.fromCharCode(0);
|
|
12
|
+
const fail = (kind,message) => Object.assign(new Error(message),{code:'vcs/branch-'+kind});
|
|
13
|
+
const check = signal => {signal?.throwIfAborted();currentOperationBudget()?.throwIfExpired();};
|
|
14
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
15
|
+
const branchName = value => {
|
|
16
|
+
if (typeof value !== 'string' || !value || value.length > 1024 || value.startsWith('-') || value.startsWith('refs/') || [...value].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127)) throw fail('unsupported','Select an existing local branch name.');
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Private, empty hooks/attributes locations are command-scoped, never repository config writes.
|
|
21
|
+
async function safeContext(repo,signal,work) {
|
|
22
|
+
check(signal);
|
|
23
|
+
if (!repo || repo.type !== 'git' || typeof repo.root !== 'string') throw fail('unsupported','Branch switching supports local Git working copies only.');
|
|
24
|
+
const root = await fs.realpath(repo.root);
|
|
25
|
+
if (root !== path.resolve(repo.root)) throw fail('changed','Repository canonical root changed; rediscover repositories.');
|
|
26
|
+
const metadata = path.join(root,'.git');
|
|
27
|
+
const metadataStat = await fs.lstat(metadata);
|
|
28
|
+
if (!metadataStat.isDirectory() || metadataStat.isSymbolicLink() || await fs.realpath(metadata) !== metadata) throw fail('unsupported','Linked, separate or symlinked Git directories are not supported.');
|
|
29
|
+
const metadataIdentity = await checkedMetadata(metadata,signal);
|
|
30
|
+
const temporary = await fs.mkdtemp(path.join(os.tmpdir(),'dsh-vcs-switch-'));
|
|
31
|
+
try {
|
|
32
|
+
const hooks = path.join(temporary,'hooks'),attributes = path.join(temporary,'attributes');
|
|
33
|
+
await fs.mkdir(hooks);await fs.writeFile(attributes,'');
|
|
34
|
+
const run = (args,max = MAX_BYTES) => {check(signal);return git(root,['-c','core.hooksPath='+hooks,'-c','core.attributesFile='+attributes,'-c','submodule.recurse=false','-c','core.ignoreStat=false','-c','core.untrackedCache=false','-c','core.protectNTFS=true','-c','core.protectHFS=true','-c','hook.post-checkout.enabled=false','-c','hook.reference-transaction.enabled=false',...args],max,{signal});};
|
|
35
|
+
const top = (await run(['rev-parse','--show-toplevel'])).toString('utf8').trim();
|
|
36
|
+
if (await fs.realpath(top) !== root || (await run(['rev-parse','--is-bare-repository'])).toString('utf8').trim() !== 'false') throw fail('unsupported','A canonical non-bare working copy is required.');
|
|
37
|
+
return await work({root,run,metadataIdentity});
|
|
38
|
+
} finally {await fs.rm(temporary,{recursive:true,force:true,maxRetries:5});}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Metadata may redirect Git writes even when the .git root itself is canonical.
|
|
42
|
+
async function checkedMetadata(directory,signal) {
|
|
43
|
+
const rows = [];let count = 0;
|
|
44
|
+
const visit = async (target,recursive) => {
|
|
45
|
+
check(signal);if (++count > 20000) throw fail('unsupported','Git metadata exceeds the safe inspection limit.');
|
|
46
|
+
let stat;try {stat = await fs.lstat(target,{bigint:true});} catch (error) {if (error.code === 'ENOENT') return;throw error;}
|
|
47
|
+
if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1n) || await fs.realpath(target) !== target || (!stat.isDirectory() && !stat.isFile())) throw fail('unsupported','Redirected Git metadata is not supported.');
|
|
48
|
+
rows.push([path.relative(directory,target),String(stat.dev),String(stat.ino)]);
|
|
49
|
+
if (recursive && stat.isDirectory()) {for (const entry of (await fs.readdir(target)).sort()) await visit(path.join(target,entry),true);}
|
|
50
|
+
};
|
|
51
|
+
await visit(directory,false);
|
|
52
|
+
for (const file of ['HEAD','index','config','config.worktree','packed-refs','shallow']) await visit(path.join(directory,file),false);
|
|
53
|
+
for (const name of ['objects','refs','logs','info']) await visit(path.join(directory,name),true);
|
|
54
|
+
for (const file of ['objects/info/alternates','objects/info/http-alternates']) {
|
|
55
|
+
try {await fs.lstat(path.join(directory,file));} catch (error) {if (error.code === 'ENOENT') continue;throw error;}
|
|
56
|
+
throw fail('unsupported','Alternate object databases are not supported.');
|
|
57
|
+
}
|
|
58
|
+
return digest(JSON.stringify(rows));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decode(buffer) {
|
|
62
|
+
try {return new TextDecoder('utf-8',{fatal:true}).decode(buffer);}
|
|
63
|
+
catch {throw fail('unsupported','Branch metadata must be valid UTF-8.');}
|
|
64
|
+
}
|
|
65
|
+
async function currentBranch(run) {
|
|
66
|
+
let ref;
|
|
67
|
+
try {ref = decode(await run(['symbolic-ref','--quiet','HEAD'])).trim();}
|
|
68
|
+
catch (error) {if (error.code !== 'VCS_COMMAND') throw error;throw fail('unsupported','Detached or unborn HEAD cannot switch branches here.');}
|
|
69
|
+
if (!ref.startsWith('refs/heads/')) throw fail('unsupported','HEAD must identify an existing local branch.');
|
|
70
|
+
let commit;
|
|
71
|
+
try {commit = decode(await run(['rev-parse','--verify',ref+'^{commit}'])).trim();}
|
|
72
|
+
catch (error) {if (error.code !== 'VCS_COMMAND') throw error;throw fail('unsupported','Unborn HEAD cannot switch branches here.');}
|
|
73
|
+
if (!isObjectId(commit)) throw fail('unsupported','Invalid current branch commit.');
|
|
74
|
+
return {ref,name:ref.slice(11),commit};
|
|
75
|
+
}
|
|
76
|
+
async function branches(run) {
|
|
77
|
+
const output = decode(await run(['for-each-ref','--sort=refname','--count='+String(MAX_BRANCHES+1),'--format=%(refname)%00%(objectname)%00%(objecttype)','refs/heads/']));
|
|
78
|
+
const lines = output ? output.trimEnd().split(LF) : [];
|
|
79
|
+
if (lines.length > MAX_BRANCHES) throw fail('unsupported','Local branch count exceeds 1000.');
|
|
80
|
+
return lines.map(line => {
|
|
81
|
+
const [ref,commit,type,...extra] = line.split(NUL);
|
|
82
|
+
if (extra.length || !ref?.startsWith('refs/heads/') || !isObjectId(commit) || type !== 'commit') throw fail('unsupported','Invalid local branch metadata.');
|
|
83
|
+
return {ref,name:ref.slice(11),commit};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function configSnapshot(run) {
|
|
87
|
+
const raw = decode(await run(['config','--null','--list']));
|
|
88
|
+
const config = [];
|
|
89
|
+
for (const record of raw.split(NUL).filter(Boolean)) {
|
|
90
|
+
const cut = record.indexOf(LF),key = (cut < 0 ? record : record.slice(0,cut)).toLowerCase(),value = cut < 0 ? '' : record.slice(cut+1);
|
|
91
|
+
if (['hook.post-checkout.enabled','hook.reference-transaction.enabled'].includes(key) && value === 'false') continue;
|
|
92
|
+
if (key === 'core.worktree') throw fail('unsupported','Separate Git working directories are not supported.');
|
|
93
|
+
if (key.startsWith('filter.') || key.startsWith('hook.')) throw fail('unsupported','Configured Git filters or command hooks are not supported for safe switching.');
|
|
94
|
+
if ((['core.sparsecheckout','core.sparsecheckoutcone','index.sparse'].includes(key) && !['false','no','0'].includes(value.toLowerCase())) || key === 'extensions.partialclone' || /^remote[.].*[.]promisor$/.test(key)) throw fail('unsupported','Sparse or partial working copies are not supported for safe switching.');
|
|
95
|
+
if (!['core.hookspath','core.attributesfile','core.fsmonitor','submodule.recurse'].includes(key)) config.push(record);
|
|
96
|
+
}
|
|
97
|
+
return digest(config.join(NUL));
|
|
98
|
+
}
|
|
99
|
+
async function absentGitState(root,run) {
|
|
100
|
+
const locations = decode(await run(['rev-parse','--git-dir','--git-common-dir'])).trim().split(LF);
|
|
101
|
+
if (locations.length !== 2) throw fail('unsupported','Invalid Git metadata directory.');
|
|
102
|
+
const directories = [...new Set(await Promise.all(locations.map(value => fs.realpath(path.resolve(root,value)))))];
|
|
103
|
+
if (directories.some(directory => directory !== path.join(root,'.git'))) throw fail('unsupported','External Git metadata is not supported.');
|
|
104
|
+
for (const directory of directories) for (const name of ['MERGE_HEAD','CHERRY_PICK_HEAD','REVERT_HEAD','REBASE_HEAD','BISECT_LOG','rebase-merge','rebase-apply','sequencer','index.lock','HEAD.lock','info/sparse-checkout','info/attributes']) {
|
|
105
|
+
try {await fs.lstat(path.join(directory,name));}
|
|
106
|
+
catch (error) {if (error.code === 'ENOENT') continue;throw error;}
|
|
107
|
+
throw fail('unsupported','Repository operation, sparse state, attributes or lock is present: '+name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function safeTree(run,commit) {
|
|
111
|
+
const raw = decode(await run(['ls-tree','-r','-z','--full-tree',commit]));
|
|
112
|
+
if (raw && !raw.endsWith(NUL)) throw fail('unsupported','Truncated tree metadata.');
|
|
113
|
+
const paths = [];
|
|
114
|
+
for (const row of raw.split(NUL).filter(Boolean)) {
|
|
115
|
+
const tab = row.indexOf(String.fromCharCode(9)),header = row.slice(0,tab),file = row.slice(tab+1);
|
|
116
|
+
if (tab < 0 || !file || !/^(100644|100755) blob [a-f0-9]{40,64}$/.test(header)) throw fail('unsupported','Symlinks, submodules or unsupported tree entries prevent safe switching.');
|
|
117
|
+
paths.push(file);
|
|
118
|
+
if (file.split('/').some(part => part.toLowerCase() === '.gitattributes')) throw fail('unsupported','Git attributes are not supported for safe switching.');
|
|
119
|
+
}
|
|
120
|
+
return {hash:digest(raw),paths};
|
|
121
|
+
}
|
|
122
|
+
async function checkWorkingPaths(root,paths) {
|
|
123
|
+
const checked = new Set();
|
|
124
|
+
for (const file of paths) {
|
|
125
|
+
if (!file || path.isAbsolute(file) || (process.platform === 'win32' && (file.includes(':') || file.includes(String.fromCharCode(92)))) || file.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) throw fail('unsupported','Invalid tracked path.');
|
|
126
|
+
let component = root;
|
|
127
|
+
for (const part of file.split('/')) {
|
|
128
|
+
component = path.join(component,part);
|
|
129
|
+
if (checked.has(component)) continue;
|
|
130
|
+
checked.add(component);
|
|
131
|
+
try {const stat=await fs.lstat(component,{bigint:true});if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1n)) throw fail('unsupported','Working tree symlinks or hardlinks prevent safe switching.');}
|
|
132
|
+
catch (error) {if (error.code !== 'ENOENT') throw error;}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function snapshotFor({root,run,metadataIdentity},name,signal) {
|
|
137
|
+
branchName(name);
|
|
138
|
+
try {await run(['check-ref-format','--branch',name]);}
|
|
139
|
+
catch (error) {if (error.code !== 'VCS_COMMAND') throw error;throw fail('unsupported','Invalid local branch name.');}
|
|
140
|
+
const config = await configSnapshot(run);
|
|
141
|
+
await absentGitState(root,run);
|
|
142
|
+
const current = await currentBranch(run);
|
|
143
|
+
const target = (await branches(run)).find(item => item.name === name);
|
|
144
|
+
if (!target) throw fail('changed','The selected local branch no longer exists.');
|
|
145
|
+
try {await run(['show-ref','--verify','--quiet','refs/tags/'+name]);throw fail('unsupported','A tag shares the selected branch name; ambiguous selections are not supported.');}
|
|
146
|
+
catch (error) {if (!(error.code === 'VCS_COMMAND' && error.exitCode === 1)) throw error;}
|
|
147
|
+
if (target.ref === current.ref) throw fail('unsupported','The selected branch is already checked out.');
|
|
148
|
+
const trees = [await safeTree(run,current.commit),await safeTree(run,target.commit)];
|
|
149
|
+
const staged = decode(await run(['ls-files','--stage','-z']));
|
|
150
|
+
for (const row of staged.split(NUL).filter(Boolean)) if (!/^[0-9]{6} [a-f0-9]{40,64} 0 /.test(row)) throw fail('dirty','Unmerged Index entries prevent switching.');
|
|
151
|
+
await checkWorkingPaths(root,trees.flatMap(tree => tree.paths));
|
|
152
|
+
const flags = decode(await run(['ls-files','-v','-z']));
|
|
153
|
+
if (flags.split(NUL).filter(Boolean).some(row => !row.startsWith('H '))) throw fail('unsupported','Assume-unchanged, skip-worktree or special Index flags prevent switching.');
|
|
154
|
+
// Filters have already been excluded before status can inspect working content.
|
|
155
|
+
const status = await run(['status','--porcelain=v1','-z','--untracked-files=all','--ignored=matching','--ignore-submodules=none']);
|
|
156
|
+
if (status.length) throw fail('dirty','Working copy must be clean, including staged, untracked and ignored files.');
|
|
157
|
+
const afterCurrent = await currentBranch(run);
|
|
158
|
+
const afterTarget = decode(await run(['rev-parse','--verify',target.ref+'^{commit}'])).trim();
|
|
159
|
+
if (JSON.stringify(current) !== JSON.stringify(afterCurrent) || target.commit !== afterTarget) throw fail('changed','Branch references changed during preview validation.');
|
|
160
|
+
check(signal);
|
|
161
|
+
const metadata = await fs.lstat(path.join(root,'.git'),{bigint:true});
|
|
162
|
+
const repositoryIdentity = [metadata.dev,metadata.ino,metadata.birthtimeNs].map(String);
|
|
163
|
+
const fingerprint = digest(JSON.stringify({current,target,config,trees,staged,flags,repositoryIdentity,metadataIdentity}));
|
|
164
|
+
return {root,current,target,fingerprint};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function listLocalBranches(repo,{signal} = {}) {
|
|
168
|
+
return safeContext(repo,signal,async({run}) => ({current:await currentBranch(run),branches:await branches(run)}));
|
|
169
|
+
}
|
|
170
|
+
export async function prepareLocalBranchSwitch(repo,{branch,signal} = {}) {
|
|
171
|
+
return safeContext(repo,signal,context => snapshotFor(context,branch,signal));
|
|
172
|
+
}
|
|
173
|
+
export async function switchLocalBranch(repo,{snapshot,signal,assertAuthorized,onStart} = {}) {
|
|
174
|
+
let started = false;
|
|
175
|
+
try {
|
|
176
|
+
if (!snapshot || snapshot.root !== repo?.root || typeof snapshot.fingerprint !== 'string' || typeof assertAuthorized !== 'function' || typeof onStart !== 'function') throw fail('changed','A current server-issued branch preview is required.');
|
|
177
|
+
return await safeContext(repo,signal,async context => {
|
|
178
|
+
const fresh = await snapshotFor(context,snapshot.target?.name,signal);
|
|
179
|
+
if (JSON.stringify(fresh) !== JSON.stringify(snapshot)) throw fail('changed','Branch selection or repository state changed; prepare again.');
|
|
180
|
+
// External Git commands do not share the Host lock. No cross-process atomicity is claimed.
|
|
181
|
+
await assertAuthorized();check(signal);
|
|
182
|
+
started = true;onStart();
|
|
183
|
+
await context.run(['switch','--no-guess','--no-overwrite-ignore','--no-recurse-submodules','--',fresh.target.name]);
|
|
184
|
+
check(signal);await assertAuthorized();check(signal);
|
|
185
|
+
const current = await currentBranch(context.run);
|
|
186
|
+
if (current.ref !== fresh.target.ref || current.commit !== fresh.target.commit) throw fail('changed','Branch changed while switching.');
|
|
187
|
+
return {current};
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (!started) throw error;
|
|
191
|
+
throw Object.assign(fail('outcome-unknown','Branch switch may have changed the working copy. Refresh repository state before any further action.'),{outcomeUnknown:true,cause:error});
|
|
192
|
+
}
|
|
193
|
+
}
|