git-sync-web 自动提交 2026-09-09 13:58:07
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Git 自动同步配置 - demo
|
||||||
|
REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo"
|
||||||
|
BRANCH="main"
|
||||||
|
REMOTES="origin=https://example.invalid/x.git"
|
||||||
|
MODE="both"
|
||||||
|
INTERVAL="60"
|
||||||
|
LOG_FILE=""
|
||||||
|
AUTH_METHOD="https"
|
||||||
|
RETRY_COUNT="0"
|
||||||
|
RETRY_INTERVAL="0"
|
||||||
|
CONFLICT_STRATEGY="merge"
|
||||||
|
PUSH_STRATEGY="manual"
|
||||||
|
AUTO_COMMIT="0"
|
||||||
|
CRON=""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[2026-09-09 13:51:38] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo)
|
||||||
|
[2026-09-09 13:51:43] [info] 监控已启动
|
||||||
|
[2026-09-09 13:51:43] [info] 已添加远程 origin → https://example.invalid/x.git
|
||||||
|
[2026-09-09 13:51:44] [info] === 开始同步(启动检查,模式 both)===
|
||||||
|
[2026-09-09 13:51:45] [error] fetch 失败: fatal: unable to access 'https://example.invalid/x.git/': CONNECT tunnel failed, response 502
|
||||||
Submodule
+1
Submodule _smoke/repo added at 39a01d89da
@@ -0,0 +1 @@
|
|||||||
|
2026/09/09 13:50:57 Git Sync Web 已启动: http://127.0.0.1:5831(鉴权:false)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Git 自动同步配置 - demo
|
||||||
|
REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo"
|
||||||
|
BRANCH="main"
|
||||||
|
REMOTES="origin=https://example.invalid/x.git"
|
||||||
|
MODE="both"
|
||||||
|
INTERVAL="60"
|
||||||
|
LOG_FILE=""
|
||||||
|
AUTH_METHOD="https"
|
||||||
|
RETRY_COUNT="0"
|
||||||
|
RETRY_INTERVAL="0"
|
||||||
|
CONFLICT_STRATEGY="merge"
|
||||||
|
PUSH_STRATEGY="manual"
|
||||||
|
AUTO_COMMIT="0"
|
||||||
|
CRON=""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[2026-09-09 13:55:33] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo)
|
||||||
Submodule
+1
Submodule _smoke2/repo added at e7bbd5cce2
@@ -0,0 +1 @@
|
|||||||
|
2026/09/09 13:54:59 Git Sync Web 已启动: http://127.0.0.1:5832(鉴权:false)
|
||||||
@@ -30,16 +30,22 @@ func gitRun(repoPath string, timeout time.Duration, args ...string) (string, err
|
|||||||
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
||||||
)
|
)
|
||||||
cmd.WaitDelay = 10 * time.Second
|
cmd.WaitDelay = 10 * time.Second
|
||||||
var buf bytes.Buffer
|
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
|
||||||
cmd.Stdout = &buf
|
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
|
||||||
cmd.Stderr = &buf
|
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
out := strings.TrimSpace(buf.String())
|
out := strings.TrimSpace(outBuf.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
||||||
}
|
}
|
||||||
msg := out
|
msg := strings.TrimSpace(errBuf.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = out
|
||||||
|
}
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = err.Error()
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-8
@@ -434,14 +434,38 @@ function updateConflictActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 差异显示(网页端) ----------
|
// ---------- 差异显示(网页端) ----------
|
||||||
|
// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入)
|
||||||
|
let diffPath = '';
|
||||||
|
// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用
|
||||||
|
let diffText = '';
|
||||||
|
|
||||||
|
function updateDiffFilterNote() {
|
||||||
|
const el = $('#diff-filter');
|
||||||
|
if (!el) return;
|
||||||
|
if (!diffPath) { el.hidden = true; el.textContent = ''; return; }
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `仅显示:${esc(diffPath)} <a href="#" id="diff-filter-clear" style="color:inherit">✕ 显示全部</a>`;
|
||||||
|
const a = $('#diff-filter-clear');
|
||||||
|
if (a) a.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
diffPath = '';
|
||||||
|
updateDiffFilterNote();
|
||||||
|
if (current) loadDiff(current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadDiff(name) {
|
function loadDiff(name) {
|
||||||
const scope = $('#f-diff-scope').value;
|
const scope = $('#f-diff-scope').value;
|
||||||
const box = $('#diffbox');
|
const box = $('#diffbox');
|
||||||
const stat = $('#diff-stat');
|
const stat = $('#diff-stat');
|
||||||
box.textContent = '加载中…';
|
box.textContent = '加载中…';
|
||||||
stat.innerHTML = '';
|
stat.innerHTML = '';
|
||||||
api(`/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`).then(d => {
|
updateDiffFilterNote();
|
||||||
renderDiff(box, d.text || '(无差异)');
|
let url = `/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`;
|
||||||
|
if (diffPath) url += `&path=${encodeURIComponent(diffPath)}`;
|
||||||
|
api(url).then(d => {
|
||||||
|
diffText = d.text || '';
|
||||||
|
renderDiff(box, diffText || '(无差异)');
|
||||||
if (d.files && d.files.length) {
|
if (d.files && d.files.length) {
|
||||||
stat.innerHTML = d.files.map(f =>
|
stat.innerHTML = d.files.map(f =>
|
||||||
`<span class="df ${f.add ? 'df-add' : ''} ${f.del ? 'df-del' : ''}">${esc(f.path)} <b style="color:var(--green)">+${f.add}</b> <b style="color:var(--red)">-${f.del}</b></span>`
|
`<span class="df ${f.add ? 'df-add' : ''} ${f.del ? 'df-del' : ''}">${esc(f.path)} <b style="color:var(--green)">+${f.add}</b> <b style="color:var(--red)">-${f.del}</b></span>`
|
||||||
@@ -470,33 +494,81 @@ function renderDiff(box, text) {
|
|||||||
|
|
||||||
// ---------- 文件编辑(网页端) ----------
|
// ---------- 文件编辑(网页端) ----------
|
||||||
let fileCurrent = null;
|
let fileCurrent = null;
|
||||||
|
const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' };
|
||||||
function loadTree(name) {
|
function loadTree(name) {
|
||||||
const list = $('#file-list');
|
const list = $('#file-list');
|
||||||
list.textContent = '加载中…';
|
list.textContent = '加载中…';
|
||||||
api(`/api/repos/${name}/tree`).then(d => {
|
api(`/api/repos/${name}/tree`).then(d => {
|
||||||
const files = d.files || [];
|
const files = d.files || [];
|
||||||
if (!files.length) { list.textContent = '(无文件)'; return; }
|
if (!files.length) { list.textContent = '(无文件)'; return; }
|
||||||
list.innerHTML = files.map(f =>
|
list.innerHTML = files.map(f => {
|
||||||
`<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}">${esc(f.path)}</div>`
|
const st = f.status || '';
|
||||||
).join('');
|
const cls = st === '?' ? 'Q' : st;
|
||||||
|
const badge = st
|
||||||
|
? `<span class="fi-status s-${esc(cls)}" title="${esc(STATUS_LABEL[st] || st)}">${esc(st)}</span>`
|
||||||
|
: '';
|
||||||
|
return `<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}"><span class="fi-name">${esc(f.path)}</span>${badge}</div>`;
|
||||||
|
}).join('');
|
||||||
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
|
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 草稿缓存(localStorage,防刷新丢稿) ----------
|
||||||
|
const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
|
||||||
|
let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
|
||||||
|
let draftTimer = null;
|
||||||
|
|
||||||
|
function showDraftNote(on) {
|
||||||
|
const el = $('#file-draft');
|
||||||
|
if (!el) return;
|
||||||
|
if (!on) { el.hidden = true; el.innerHTML = ''; return; }
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = '已恢复未保存的草稿 <a href="#" id="draft-discard" style="color:inherit">放弃草稿</a>';
|
||||||
|
const a = $('#draft-discard');
|
||||||
|
if (a) a.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.removeItem(draftKey(current, fileCurrent));
|
||||||
|
$('#file-editor').value = draftServer;
|
||||||
|
showDraftNote(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑时防抖缓存草稿;内容与磁盘一致时自动清除
|
||||||
|
function scheduleDraft() {
|
||||||
|
if (!current || !fileCurrent) return;
|
||||||
|
if (draftTimer) clearTimeout(draftTimer);
|
||||||
|
draftTimer = setTimeout(() => {
|
||||||
|
const editor = $('#file-editor');
|
||||||
|
if (!editor || editor.disabled) return;
|
||||||
|
const v = editor.value;
|
||||||
|
if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent));
|
||||||
|
else localStorage.setItem(draftKey(current, fileCurrent), v);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFile(name, path) {
|
async function loadFile(name, path) {
|
||||||
const editor = $('#file-editor');
|
const editor = $('#file-editor');
|
||||||
editor.value = '加载中…';
|
editor.value = '加载中…';
|
||||||
editor.disabled = false;
|
editor.disabled = false;
|
||||||
$('#file-current').textContent = path;
|
$('#file-current').textContent = path;
|
||||||
fileCurrent = path;
|
fileCurrent = path;
|
||||||
|
showDraftNote(false);
|
||||||
try {
|
try {
|
||||||
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
||||||
if (d.binary) {
|
if (d.binary) {
|
||||||
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||||||
editor.disabled = true;
|
editor.disabled = true;
|
||||||
} else {
|
draftServer = '';
|
||||||
editor.value = d.content || '';
|
return;
|
||||||
editor.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
draftServer = d.content || '';
|
||||||
|
const saved = localStorage.getItem(draftKey(name, path));
|
||||||
|
if (saved !== null && saved !== draftServer) {
|
||||||
|
editor.value = saved;
|
||||||
|
showDraftNote(true);
|
||||||
|
} else {
|
||||||
|
editor.value = draftServer;
|
||||||
|
}
|
||||||
|
editor.disabled = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
editor.value = '读取失败:' + err.message;
|
editor.value = '读取失败:' + err.message;
|
||||||
editor.disabled = true;
|
editor.disabled = true;
|
||||||
@@ -517,6 +589,10 @@ async function saveFile(commit) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, content })
|
body: JSON.stringify({ path, content })
|
||||||
});
|
});
|
||||||
|
// 写盘成功 → 草稿作废
|
||||||
|
localStorage.removeItem(draftKey(name, path));
|
||||||
|
showDraftNote(false);
|
||||||
|
draftServer = content;
|
||||||
if (commit) {
|
if (commit) {
|
||||||
await api(`/api/repos/${name}/commit`, {
|
await api(`/api/repos/${name}/commit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -535,7 +611,40 @@ async function saveFile(commit) {
|
|||||||
|
|
||||||
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||||
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
||||||
|
|
||||||
|
// 差异:复制 / 下载 .patch
|
||||||
|
$('#btn-diff-copy').addEventListener('click', async () => {
|
||||||
|
if (!diffText) { alert('当前没有可复制的差异内容'); return; }
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(diffText);
|
||||||
|
alert('差异内容已复制到剪贴板');
|
||||||
|
} catch (err) {
|
||||||
|
alert('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#btn-diff-download').addEventListener('click', () => {
|
||||||
|
if (!diffText) { alert('当前没有可下载的差异内容'); return; }
|
||||||
|
const scope = $('#f-diff-scope').value;
|
||||||
|
const blob = new Blob([diffText], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${current || 'repo'}-${scope}.patch`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
|
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
|
||||||
|
|
||||||
|
// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
|
||||||
|
$('#btn-file-diff').addEventListener('click', () => {
|
||||||
|
if (!current || !fileCurrent) { alert('请先在左侧选择一个文件'); return; }
|
||||||
|
diffPath = fileCurrent;
|
||||||
|
$('#f-diff-scope').value = 'working';
|
||||||
|
showTab('diff');
|
||||||
|
});
|
||||||
|
|
||||||
$('#file-list').addEventListener('click', (e) => {
|
$('#file-list').addEventListener('click', (e) => {
|
||||||
const item = e.target.closest('.file-item');
|
const item = e.target.closest('.file-item');
|
||||||
if (!item || !current) return;
|
if (!item || !current) return;
|
||||||
@@ -544,6 +653,15 @@ $('#file-list').addEventListener('click', (e) => {
|
|||||||
$('#btn-file-save').addEventListener('click', () => saveFile(false));
|
$('#btn-file-save').addEventListener('click', () => saveFile(false));
|
||||||
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
|
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
|
||||||
|
|
||||||
|
// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存
|
||||||
|
$('#file-editor').addEventListener('input', scheduleDraft);
|
||||||
|
$('#file-editor').addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveFile(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- 统计 ----------
|
// ---------- 统计 ----------
|
||||||
function dateStr(d) {
|
function dateStr(d) {
|
||||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
@@ -607,6 +725,7 @@ function fillForm(r) {
|
|||||||
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
|
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
|
||||||
$('#f-cron').value = r ? (r.cron || '') : '';
|
$('#f-cron').value = r ? (r.cron || '') : '';
|
||||||
$('#f-log').value = r ? (r.log_file || '') : '';
|
$('#f-log').value = r ? (r.log_file || '') : '';
|
||||||
|
previewCron();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readForm() {
|
function readForm() {
|
||||||
@@ -674,6 +793,43 @@ $('#btn-sync-all').addEventListener('click', () => {
|
|||||||
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 暗色模式 ----------
|
||||||
|
const THEME_KEY = 'gitautosync:theme';
|
||||||
|
function applyTheme(dark) {
|
||||||
|
document.documentElement.classList.toggle('dark', !!dark);
|
||||||
|
const btn = $('#btn-theme');
|
||||||
|
if (btn) btn.textContent = dark ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
function initTheme() {
|
||||||
|
applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
|
||||||
|
}
|
||||||
|
$('#btn-theme').addEventListener('click', () => {
|
||||||
|
const dark = !document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
|
||||||
|
applyTheme(dark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Cron 下次执行预览 ----------
|
||||||
|
let cronTimer = null;
|
||||||
|
async function previewCron() {
|
||||||
|
const el = $('#cron-next');
|
||||||
|
if (!el) return;
|
||||||
|
const expr = $('#f-cron').value.trim();
|
||||||
|
if (!expr) { el.textContent = ''; el.className = 'cron-next'; return; }
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/cron-next?expr=${encodeURIComponent(expr)}`);
|
||||||
|
el.textContent = '下次执行:' + d.next;
|
||||||
|
el.className = 'cron-next good';
|
||||||
|
} catch (err) {
|
||||||
|
el.textContent = '表达式无效:' + err.message;
|
||||||
|
el.className = 'cron-next bad';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#f-cron').addEventListener('input', () => {
|
||||||
|
if (cronTimer) clearTimeout(cronTimer);
|
||||||
|
cronTimer = setTimeout(previewCron, 400);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- 初始化 ----------
|
// ---------- 初始化 ----------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
@@ -684,6 +840,7 @@ async function refresh() {
|
|||||||
} catch (e) { /* 忽略,SSE 会重试 */ }
|
} catch (e) { /* 忽略,SSE 会重试 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initTheme();
|
||||||
refresh();
|
refresh();
|
||||||
connect();
|
connect();
|
||||||
setInterval(refresh, 20000);
|
setInterval(refresh, 20000);
|
||||||
|
|||||||
Reference in New Issue
Block a user