dsh-gitbar-lite 0.3.0 → 0.4.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.en.md CHANGED
@@ -23,7 +23,9 @@ The column is narrow (300px minimum, and a narrow viewport makes it full-screen)
23
23
  | Commit | Commits exactly what is ticked — because ticking wrote the index |
24
24
  | Commit and Push | Commits, then pushes. **A failed push never claims the commit failed**: the commit happened, and the panel says "committed `abc1234`, but the push failed" |
25
25
  | Context button | **Push** when local commits are unpushed, **Publish** when the branch has no upstream, **Fetch** otherwise |
26
- | Automatic fetch | Every five minutes while the panel is visible and the repository has a remote (`autoFetchMs`, `0` to disable). Failures are silent — no error banner |
26
+ | Fetch before push | A push runs `git fetch --prune` first, then pushes. **Being behind refuses the push** and says by how many — git never merges on a push, so it would be rejected either way, and knowing before the attempt beats being told after. A failed fetch does not block the push |
27
+ | Automatic fetch | GitHub Desktop's rhythm while the panel is visible and the repository has a remote: an hour by default, each tick re-asking whether to run, the next scheduled from the *completion* of the last, with a random skew so panels do not sync up. The pace is `autoFetchMs` (`0` to disable), and "was it fetched recently" is answered by `FETCH_HEAD`'s mtime — so a fetch in a terminal counts too |
28
+ | Panel regains focus | The file list refreshes when the panel becomes visible again; if a script is configured, it runs and fills the Summary |
27
29
  | Discard | One file or all of them; the diff is archived *before* anything is restored, and the confirmation offers "delete for good" |
28
30
  | Trash | Its own tab. Every discard keeps a diff, recorded **against the commit it was taken from** (`abc1234 initial commit`) |
29
31
  | Diff preview | Double-click a file, or a trash entry, to open a diff tab in the column — **one tab, ever**, re-navigated in place |
@@ -222,7 +224,7 @@ are in [`docs/CONTRACT.md`](./docs/CONTRACT.md).
222
224
 
223
225
  ```sh
224
226
  npm test # unit + real-git e2e + artifact self-check
225
- npm run test:e2e # the real-git suite alone (21 sections building their own fixtures, 263 assertions)
227
+ npm run test:e2e # the real-git suite alone (22 sections building their own fixtures, 277 assertions)
226
228
  npm run check # artifact self-check alone (catches boot-breaking mistakes without restarting DSH)
227
229
  npm run test:live # probe a running DSH (not part of npm test; needs the app running)
228
230
  ```
package/README.md CHANGED
@@ -23,7 +23,9 @@ DSH 的右栏本来就是一个常驻的标签区。Git 面板放这里,意味
23
23
  | Commit | 只提交勾上的(因为勾选写进了索引) |
24
24
  | Commit and Push | 提交后立刻推送;**推送失败不会说「提交失败」**——提交已经发生了,面板会明说「已提交 xxx,但推送失败」 |
25
25
  | 上下文按钮 | 有本地未推送提交时是 **Push**,没有上游时是 **Publish**,否则是 **Fetch** |
26
- | 自动 Fetch | 面板可见且仓库有远端时,每 5 分钟自己 fetch 一次(`autoFetchMs` 可调,`0` 关掉)。失败是静默的,不弹错误 |
26
+ | Push 前先 Fetch | 推送前会先 `git fetch --prune`,然后才推。**已经落后就拒绝推**并说明落后几个——git 的 push 从不合并,早晚都是被拒,先知道比推完再被拒有用。fetch 本身失败不阻断推送 |
27
+ | 自动 Fetch | 面板可见且仓库有远端时按 GitHub Desktop 的口径自己抓:默认 1 小时,每次到点重新判断,下一次从**上一次完成后**开始排,并加随机抖动防止多个面板同步。节奏是 `autoFetchMs`(`0` 关掉),并且用 `FETCH_HEAD` 的 mtime 判断"是不是刚抓过"——你在终端里抓也算 |
28
+ | 面板获得焦点 | 面板重新可见时刷新文件列表;配了脚本就顺带跑一次填 Summary |
27
29
  | 舍弃更改 | 单个文件或「全部舍弃」;动手前先把这次改动的 diff 归档,确认框里可以勾「彻底删除」 |
28
30
  | 回收站 | 独立 tab。每次舍弃存一份 diff,并记下**是对哪个提交(`abc1234 初始提交`)取的 diff** |
29
31
  | diff 预览 | 双击文件,或点回收站里的一条,在右栏开一个 diff 标签页——**永远只占一个**,再点就复用 |
@@ -202,7 +204,7 @@ lib/client.js 浏览器半边:三个 tab 类型 + 面板/回收站/di
202
204
 
203
205
  ```sh
204
206
  npm test # 单测 + 真 git 的 e2e + 产物自检
205
- npm run test:e2e # 只跑真 git 的 e2e(21 个小节,各自造自己的夹具,263 项断言)
207
+ npm run test:e2e # 只跑真 git 的 e2e(22 个小节,各自造自己的夹具,277 项断言)
206
208
  npm run check # 只用产物自检(不启 DSH 也能发现会让 DSH 起不来的问题)
207
209
  npm run test:live # 探测一个正在运行的 DSH(不在 npm test 里,需要跑着应用)
208
210
  ```
package/docs/CONTRACT.md CHANGED
@@ -326,7 +326,7 @@ this package on the next upgrade.
326
326
  | state files, the binding key, corruption tolerance, write serialization | `test/store.test.mjs` |
327
327
  | the route carrier, single registration under a hostile service order, error classification | `test/index.test.mjs` |
328
328
  | every registration identity, the wire literals, the dictionaries, and the hook-free components' rendered trees | `test/client.test.mjs` |
329
- | the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`, 263 assertions over 21 sections that build their own fixtures |
329
+ | the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`, 277 assertions over 22 sections that build their own fixtures |
330
330
  | the manifest, the patch layer, the tarball's contents, the two copies of the wire contract, the dictionaries, the workflow | `test/check.mjs` |
331
331
  | that the client bundle would not abort the Host boot | `scripts/check-client-bundle.mjs` |
332
332
 
package/lib/client.js CHANGED
@@ -1592,6 +1592,14 @@ window.__ModuleLoader__.load({
1592
1592
  setSummary('')
1593
1593
  setDescription('')
1594
1594
  setDescriptionOpen(false)
1595
+ // Clear the "already auto-selected this set" guard, so the
1596
+ // select-by-default rule applies again to whatever is left. GitHub
1597
+ // Desktop's equivalent is that a rebuilt working-directory list has
1598
+ // every new file included; here the same effect comes from letting
1599
+ // the existing rule run once more. A clean tree makes it a no-op,
1600
+ // and a partly-ticked commit leaves the rest ticked, which is what
1601
+ // Desktop does.
1602
+ autoStaged.current = ''
1595
1603
  const short = data.commit?.short ?? ''
1596
1604
  if (andPush && data.push?.ok === false) {
1597
1605
  flash('warn', `${t('toast.pushFailed', { commit: short })}:${errorText(t, data.push.error?.code, data.push.error?.message)}`)
package/lib/host/repo.js CHANGED
@@ -436,8 +436,30 @@ export function createRepo(git, config) {
436
436
  * @param {string} repoRoot
437
437
  * @param {AbortSignal} [signal]
438
438
  */
439
+ /**
440
+ * Push the current branch, publishing it when it has no upstream yet.
441
+ *
442
+ * A fetch runs first. GitHub Desktop goes further — fetch, then pull, then push
443
+ * — but a pull rewrites the working tree, and this panel's whole model is that
444
+ * a tick is the real index: a merge commit appearing under the user between
445
+ * their tick and their push would quietly invalidate what they selected. So the
446
+ * fetch is followed by a check instead, and the push is refused with a sentence
447
+ * when the branch has fallen behind.
448
+ *
449
+ * That refusal is the honest outcome, not a limitation: git never merges on
450
+ * push, so a behind branch is rejected either way. What the fetch buys is that
451
+ * the answer is known *before* the push attempt, and that the upstream tracking
452
+ * ref the ahead/behind count is read from is current rather than however stale
453
+ * it was when the panel opened.
454
+ *
455
+ * The fetch itself is best-effort: if it fails (offline, no credential) the
456
+ * push still goes ahead, because the fetch's failure says nothing about whether
457
+ * the push can work, and silently refusing to push would be worse.
458
+ *
459
+ * @param {string} repoRoot
460
+ * @param {AbortSignal} [signal]
461
+ */
439
462
  async function push(repoRoot, signal) {
440
- const { branch } = await statusOf(repoRoot, signal)
441
463
  const remotesResult = await git.run(['remote'], { cwd: repoRoot, signal })
442
464
  const remotes =
443
465
  remotesResult.exitCode === 0
@@ -448,13 +470,36 @@ export function createRepo(git, config) {
448
470
  : []
449
471
  const remote = pickRemote(remotes)
450
472
 
473
+ let fetched = false
474
+ const preFetch = await git.run(['fetch', '--prune', remote], { cwd: repoRoot, signal, maxBytes: 1024 * 1024 })
475
+ if (preFetch.exitCode === 0) fetched = true
476
+
477
+ // Re-read after the fetch: the branch's upstream and its ahead/behind counts
478
+ // are exactly what the fetch may have changed.
479
+ const { branch } = await statusOf(repoRoot, signal)
480
+
481
+ // Only refuse when we *know* we are behind, which requires both an upstream
482
+ // and a fetch that actually told us something. Without an upstream this is a
483
+ // publish, and a failed fetch leaves the counts unknown — neither should be
484
+ // turned into a refusal.
485
+ if (fetched && branch.upstream !== null && branch.behind > 0) {
486
+ const refusal = new RepoError(
487
+ ERROR_CODES.PUSH_REJECTED,
488
+ `远端有新提交(落后 ${branch.behind} 个),先 Pull 或 Rebase 再推。`,
489
+ )
490
+ // Carried on the error so the route can report "we did look" alongside the
491
+ // refusal, rather than the caller having to guess whether a fetch happened.
492
+ refusal.fetched = true
493
+ throw refusal
494
+ }
495
+
451
496
  const args =
452
497
  branch.upstream === null ? ['push', '--set-upstream', remote, 'HEAD'] : ['push', remote]
453
498
  const result = await git.run(args, { cwd: repoRoot, signal, maxBytes: 1024 * 1024 })
454
499
  if (result.exitCode !== 0) {
455
500
  throw classifyGitFailure({ ...result, message: `git push failed: ${result.stderr.trim()}` })
456
501
  }
457
- return { remote, published: branch.upstream === null, output: `${result.stdout}${result.stderr}`.trim() }
502
+ return { remote, published: branch.upstream === null, fetched, output: `${result.stdout}${result.stderr}`.trim() }
458
503
  }
459
504
 
460
505
  /**
@@ -261,7 +261,11 @@ export function createHandlers({ ctx, git, repo, store }) {
261
261
  try {
262
262
  push = { ok: true, ...(await repo.push(repoRoot, request.signal)) }
263
263
  } catch (error) {
264
- push = { ok: false, error: describeError(error) }
264
+ // `fetched` is carried out of a refusal because it is the difference
265
+ // between "the push was rejected, fetch and try again" and "we already
266
+ // looked, and the branch really is behind" — the same sentence reads very
267
+ // differently depending on which one it is.
268
+ push = { ok: false, error: describeError(error), fetched: error?.fetched === true }
265
269
  }
266
270
 
267
271
  const state = await repo.readStatus(repoRoot, request.signal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbar-lite",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A GitHub Desktop-style Git panel for DSH: changed files, tick-to-stage, Summary + Description, Commit and Commit and Push, discard-to-trash, and diff preview, in the built-in right sidebar.",
5
5
  "type": "module",
6
6
  "license": "MIT",