diff --git a/README.md b/README.md index 0fdf26d..5a46193 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the | **Understand structure** | JSON, YAML and XML compared as _data_: reordering keys or reformatting stops counting, and unchanged keys collapse away. | | **Excel** | `.xlsx` workbooks as aligned grids — sheet tabs and cell-level highlights, with inserted rows and columns that don't cascade into false changes. Dates read as dates, hidden sheets and rows are marked, and formulas are compared as well as their results, so a total pasted over the formula behind it is caught rather than shown as unchanged. Headers are found under a title row rather than assumed to be row 1, and rows can be paired by the columns that name them — one column or several — so the same export sorted differently reads as the one figure that moved instead of as a rewrite. Set a tolerance — one of the presets or a threshold of your own, percentage or raw — and rounding noise stops counting; export the whole change list as a CSV. | | **CSV** | `.csv` and `.tsv` compare as text or, one toggle away, as the same grid — rows aligned by their first column, quoted fields kept whole. | +| **Dependencies** | A lockfile pair reads as the dependency moves it describes, not as the four thousand lines it is written in: which packages were added, removed, bumped or downgraded, the semver step of each, and — the part that matters — which of them you actually asked for rather than got carried along. `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `go.sum` and `composer.lock`. Nothing is fetched; every fact comes out of the file in front of you. | | **Huge files** | Past 32 MB a file is indexed by line instead of loaded, and the rows you're looking at are read from disk as you scroll — a multi-gigabyte log opens in seconds. Marked as streamed, with the few actions that need the whole text saying so rather than half-working. | | **Keep** | Saved diffs: encrypted, tagged, optionally auto-expiring. Drag a row onto another to arrange the list yourself; starred rows stay above the rest. Your open tabs come back on the next launch, and the strip can be told to close the oldest comparison to make room for a new one. | | **Share** | One signed file only the recipients you ticked can open, carrying the expiry you chose so every copy dies at the same moment. Give a trusted key an email address and Diff Bro opens an addressed message in your own mail app with the sealed file on the clipboard — it never sends anything itself. The key swap rides the same rails: email your key from the My key dialog, and a key copied out of any chat app is offered — fingerprint first — when you press + Trusted key. | @@ -61,7 +62,8 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the | **Guided first run** | Six coach marks over the real controls on a first launch — comparing, sealing, the library, then the way into Settings and around it — with four more if you want them. Each step points at a control and its button performs the action, so nothing opens unannounced. Back revisits a step, and everything it put on screen — the demo files, the example snippet — leaves when it does. Escape or Skip ends it for good; Help ▸ Show Tour brings it back. | | **Diagrams** | Two Mermaid files compare as a picture, not as text — one diagram carrying both revisions, so an inserted node reads as one change instead of a rewrite. | | **Tools** | JSON, Base64, UUID, JWT, Epoch, URL, Lines, XML, checksums, a regex tester, find & replace, text encryption — rich panels, not blank text boxes. All of them live in their own sidebar section; star the ones you reach for and they stay at the top. | -| **Terminal** | `diffbro compare a.json b.json` opens a comparison in the running app, `diffbro open` raises it, `diffbro backup ` writes an encrypted archive. No port, no daemon. | +| **Merge conflicts** | Registered as git's `difftool` **and** `mergetool`: `git mergetool` opens the two conflicting versions and takes a choice per conflict — ours, theirs, both or neither — then writes the merged file back and tells git it is done. This is the one file Diff Bro writes over; everything else it produces is a new file you picked the place for. | +| **Terminal** | `diffbro compare a.json b.json` opens a comparison in the running app, and either side can name a git revision instead of a file — `diffbro compare HEAD~1:src/app.js src/app.js` reads the old copy straight out of the repository, so you never have to produce one first. `diffbro open` raises the app, `diffbro backup ` writes an encrypted archive. No port, no daemon. | | **Yours to arrange** | Fourteen themes (Nord, Sepia, Solar, Nyan, Matrix, plus accessibility-grade Contrast and Beacon), shared tags, adjustable limits. |
diff --git a/docs/brand/roadmap.svg b/docs/brand/roadmap.svg index 4e90c40..2dafede 100644 --- a/docs/brand/roadmap.svg +++ b/docs/brand/roadmap.svg @@ -5,7 +5,7 @@ sidebar. Track hues are real theme accents from src/renderer/src/utils/themes.js (Dim, Bloom, Neon, Beacon) — colour depth encodes sequence: solid now, faded later. Keep this in step with the items in docs/roadmap.md. --> - + @@ -16,22 +16,22 @@ - + - + Diff Bro roadmap - Four tracks. Depth of colour is sequence — solid first, faded last. + Five tracks. Depth of colour is sequence — solid first, faded last. V0.4.27 · PLANNED + font-family="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#a99a7f">V0.4.29 · PLANNED @@ -72,10 +72,10 @@ diagramUnion.js - + Comparing more 3 + font-family="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#8d8168">2 @@ -84,11 +84,8 @@ Image pairs - - Three-way merge - - - a decision first + + a decision first @@ -106,18 +103,37 @@ electron-builder.yml:80 + + + Developer workflow + 3 + + + + Dependencies · lockfiles + + + Compare a git revision + + + Resolve a merge + + + utils/lockfile/ + - + - - now + + now - - next + + next - - later + + later - Each card ends with the file the work starts from. + Each card ends with the file the work starts from. diff --git a/docs/glossary.md b/docs/glossary.md index 832cd16..cdffdb7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -84,6 +84,14 @@ where the concept lives in this repo. that cells reference by index. - **LCS** — _Longest Common Subsequence_, the classic diff algorithm; used to align spreadsheet rows by default and to build the copy-as-patch output. +- **Lockfile** — the file a package manager writes to pin the exact version of + every dependency actually installed (`package-lock.json`, `pnpm-lock.yaml`, + `yarn.lock`, `go.sum`, `composer.lock`). Diff Bro reads five of them as + dependency SETS rather than as text (`utils/lockfile/`). +- **Direct vs transitive** — a direct (or "asked for") dependency is one the + project's own manifest names; a transitive one was pulled in by something else. + Only some lockfile formats record the difference, and the ones that do not say + so rather than guessing. - **Key column** — a column whose value names a row, so the two sides pair by identity rather than by position or whole-row contents; several together make a **composite key** (an account plus a cost centre). Chosen per sheet in the diff --git a/docs/ipc-security.md b/docs/ipc-security.md index 691233b..0d2c515 100644 --- a/docs/ipc-security.md +++ b/docs/ipc-security.md @@ -87,6 +87,7 @@ that enforces each: | **Backup deletion by age, never by name** | `backup:prune` is the only handler that DELETES. It takes an age in days that must be one of the two the app offers (`PRUNE_DAYS`), never a path or a filename, so the renderer cannot name a file to remove; every candidate comes from `listBackups`, which yields only names that parse as one of ours, so anything else sharing the folder is untouched | `backupRoute.js`, `autoBackup.js` | | **The mail hand-off supplies no URL and no path** | `mail:handoff` takes fingerprints and text. Main resolves the addresses from the trust store, BUILDS the `mailto:` (`mailto.js`), and re-checks it with `isSafeMailtoUrl` before `shell.openExternal` — `mailto:` only, and an `attach`/`attachment` parameter is refused rather than ignored. The file it copies and reveals is the path it just sealed, never one round-tripped through the renderer | `mail.js`, `mailto.js`, `linkPolicy.js`, `mailAddress.js` | | **Copy as file takes bytes, never a path** | `clipboard:writeFile` receives content and a DISPLAY NAME. Main slugs the name flat (so `../../.ssh/config` cannot traverse), stages it in a `0o700` directory, and puts that path on the clipboard. The renderer cannot name a file to stage, read one back, or learn the staging directory; staged copies are pruned at 30 minutes and swept on quit **and** on next launch | `clipboardCopy.js`, `clipboardStage.js`, `clipboardWrite.js` | +| **The merge write takes TEXT, never a path** | `merge:write` is the only handler that writes over a file the user already had, and it can only write the `$MERGED` path main was launched with by `git mergetool`. The renderer sends the resolved text; there is no argument for a filename. With no merge launch in progress the handler writes nothing at all, and one launch permits one write | `mergeSession.js`, `cliRoute.js` | | **The tray settings are booleans** | `tray:supported`, `app:startAtLogin` and `app:setStartAtLogin` take and return nothing but booleans. The login item registers `process.execPath` — main's own — with a fixed `--hidden` argument; the renderer never supplies an executable, an argument or a registry key, and there is no handler that would accept one | `tray.js`, `trayCore.js` | | **A stored address cannot become a header** | `share:setTrustedEmail` refuses anything carrying CR/LF, a comma, a semicolon, angle brackets or whitespace, **before it reaches disk** — otherwise a stored address would inject a second header into the hand-off URL. A restored backup's `email` field is dropped if it fails the same check | `trustedKeys.js`, `mailAddress.js`, `shareCore.js` | | **No injection sinks** | `v-html`, `eval`, `new Function`, `innerHTML` are ESLint-banned | `eslint.config.mjs` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2f32b11..cd258e7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # Roadmap Roadmap board — four tracks. Spreadsheet · finance: amounts read as amounts, delta and net variance, reading a big diff, caps that announce themselves. Diagrams: sequence · gantt · pie, click a change to pan to it. Comparing more: folder compare, image pairs, three-way merge — a decision first. Signing: macOS Developer ID, Windows deferred. + alt="Roadmap board — five tracks. Developer workflow, shipped: dependencies as lockfiles, compare a git revision, resolve a merge. Spreadsheet · finance: amounts read as amounts, delta and net variance, reading a big diff, caps that announce themselves. Diagrams: sequence · gantt · pie, click a change to pan to it. Comparing more: folder compare, image pairs, three-way merge — a decision first. Signing: macOS Developer ID, Windows deferred."> Board is `docs/brand/roadmap.svg` — hand-authored, edit it alongside the sections below. @@ -175,6 +175,47 @@ flowchart LR --- +## Developer workflow + +**Built.** The three artifacts a developer spends the day on, read the way the +rest of the app reads a spreadsheet: as meaning, not as lines. + +```mermaid +flowchart LR + subgraph deps["dependencies"] + l["utils/lockfile/ — npm · pnpm · yarn · go · composer"] + d["lockDiff.js — added · removed · bumped
direct vs carried"] + l --> d + end + subgraph git["revisions"] + g["main/gitRepo.js — fenced rev-parse + show"] + c["compare HEAD~1:path"] + g --> c + end + subgraph merge["merge"] + m["mergeConflicts.js — regions + four resolutions"] + w["mergeSession.js — the one write"] + m --> w + end +``` + +- **Dependencies** — a lockfile pair reads as the packages that moved and which + of them you asked for. Nothing is fetched; every fact is in the file +- **Revisions** — `diffbro compare HEAD~1:src/app.js src/app.js`. `git show` + behind a fence: fixed argv, no shell, the repo root computed in main, hooks + and the fsmonitor disabled, every inherited `GIT_*` dropped +- **Merge** — `git mergetool` now finishes. This CROSSES "Diff Bro never writes + files", deliberately: the app had already registered for the job. Main writes + only the `$MERGED` path it was launched with, the renderer sends text and + never a path, and the launcher waits so `trustExitCode` is honest + +**Open.** TOML lockfiles (`Cargo.lock`, `poetry.lock`) need a parser this repo +does not have. A revision PICKER — the app takes a revision, it is not a git +client. Breaking-change classification for OpenAPI and GraphQL, which is the +same thesis pointed at a contract. + +--- + ## Comparing more **Open — and gated on a decision, not a build.** Each of these is its own @@ -186,7 +227,6 @@ flowchart LR direction TB f["folder compare
two trees aligned by path"] i["image pairs
side-by-side · onion-skin · pixel Δ"] - t["three-way merge
an EDITOR, not a viewer"] end ``` @@ -196,9 +236,6 @@ flowchart LR - **image pairs** — the adapter registry already takes a `{ kind }` comparable, so the seam exists; which diff to draw (side-by-side, onion-skin, pixel delta) is the decision -- **three-way merge** — WRITES files, which Diff Bro deliberately never does - today; the same line the Diagrams track holds ("editing a diagram from the - diff view" is out of scope). Crossing it is the decision, not the code --- diff --git a/docs/security.md b/docs/security.md index 98b8ab3..6731bbd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,6 +20,55 @@ rejected rather than shipped, because the kill switch would not have caught it: it filters `session.defaultSession.webRequest`, which is Chromium traffic only, so a main-process `tls.connect` would have been invisible to it. +**Reading a file out of git does not change it either.** `src/main/gitRepo.js` +spawns `git` to answer two questions — where the repository begins, and what a +file looked like at a revision — the same way `gitTool.js` already spawns it to +register the difftool. A subprocess is not a network client, and the vocabulary +is `rev-parse` and `show`: nothing that can fetch, pull, clone or `ls-remote` is +reachable, so git cannot be asked to open a socket on the app's behalf. + +The fence around it, because a repository someone cloned is untrusted input and +repo-local config has been an execution vector before: + +- `execFile` with a FIXED argv, never a shell. +- **No renderer reaches git at all.** There is no `git:*` IPC handler: the only + caller is the CLI, whose arguments main parsed itself, and the file it reads + out of a revision is staged and vouched for through `allowCliPath` like any + other path. An IPC that took a path from the renderer would be an + arbitrary-file-read primitive for anything committed in any repository on the + machine, which is exactly what `files.js`'s allowlist exists to prevent. +- A revision is validated against a narrow pattern, may never begin with `-`, + and is followed by `--end-of-options`; a path may not be absolute and may not + contain `..`. +- Every invocation carries `core.hooksPath=`, `core.fsmonitor=` (a command git + will otherwise SPAWN), `core.editor=true` and `protocol.ext.allow=never`. +- The environment is rebuilt rather than inherited: every `GIT_*` variable is + dropped, because `GIT_DIR`, `GIT_INDEX_FILE` and + `GIT_ALTERNATE_OBJECT_DIRECTORIES` each redirect git at something other than + the repository main chose. `GIT_CONFIG_NOSYSTEM=1` and + `GIT_TERMINAL_PROMPT=0` are then set. +- A blob is capped at 32 MB — past that the streamed reader is the right tool, + and a revision is no reason to hold more in memory than a file would be. + +## Writing a merged file + +Diff Bro writes over a file you already had in exactly one situation: a +`git mergetool` run it was invoked for. Everything else it produces is a NEW +file you chose the location of. + +The fence is the shape of the surface rather than a check inside it. Main +remembers the `$MERGED` path from the LAUNCH argv; `merge:write` takes the +resolved **text** and nothing else, so there is no argument through which the +renderer could name a file — the same shape as `clipboard:writeFile`, which +takes bytes and a display name. With no mergetool launch there is no path held, +so the handler writes nothing at all whatever arrives, and a session is spent +once used. + +`mergetool.diffbro.trustExitCode` is `true`, which is only honest because the +merge launcher WAITS for `$MERGED` to change before it exits. The app is +single-instance, so the launch itself returns immediately; a script that exited +there would tell git the conflict was resolved before anyone had looked at it. + ## File access (compromised-renderer threat model) All filesystem access lives in the main process; the renderer only asks. Because diff --git a/e2e/deps.spec.mjs b/e2e/deps.spec.mjs new file mode 100644 index 0000000..02a859a --- /dev/null +++ b/e2e/deps.spec.mjs @@ -0,0 +1,113 @@ +import { test, expect, setViewOption, stubOpenDialog } from './fixtures.mjs' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// A lockfile pair only means anything through a real launch: the file is read by +// the main process, recognised by NAME, and routed to a viewer that is neither +// Monaco nor the structure tree. + +const lockfile = (entries) => + JSON.stringify( + { + lockfileVersion: 3, + name: 'demo', + packages: { + '': { + name: 'demo', + dependencies: { vue: '^3.4.0' }, + devDependencies: { vitest: '^4.0.0' } + }, + ...Object.fromEntries( + entries.map(([name, version, dev]) => [ + `node_modules/${name}`, + { version, license: 'MIT', ...(dev ? { dev: true } : {}) } + ]) + ) + } + }, + null, + 2 + ) + +const BEFORE = lockfile([ + ['vue', '3.4.21'], + ['vitest', '4.1.9', true], + ['@vue/shared', '3.4.21'], + ['gone', '1.0.0'] +]) +const AFTER = lockfile([ + ['vue', '3.5.13'], + ['vitest', '4.1.9', true], + ['@vue/shared', '3.5.13'] +]) + +function pair(dir) { + const left = join(dir, 'before') + const right = join(dir, 'after') + mkdirSync(left) + mkdirSync(right) + writeFileSync(join(left, 'package-lock.json'), BEFORE) + writeFileSync(join(right, 'package-lock.json'), AFTER) + return [join(left, 'package-lock.json'), join(right, 'package-lock.json')] +} + +async function openPair(app, page, dir) { + const [l, r] = pair(dir) + await stubOpenDialog(app, [l]) + await page.locator('.slot[data-side="left"]').click() + await stubOpenDialog(app, [r]) + await page.locator('.slot[data-side="right"]').click() +} + +test('two lockfiles open as dependencies, not as text', async ({ app, page }) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-deps-')) + try { + await openPair(app, page, dir) + + // The dependency view took over without being asked: it is what the files + // are for, the way a Mermaid pair opens as a picture. + await expect(page.locator('.deps-diff')).toBeVisible({ timeout: 20000 }) + await expect(page.locator('.monaco-diff-editor')).toHaveCount(0) + + // What the reader asked for is on screen; what came with it is folded away. + await expect(page.locator('.deps-row')).toHaveCount(1) + const row = page.locator('.deps-row').first() + await expect(row).toContainText('vue') + await expect(row).toContainText('3.4.21') + await expect(row).toContainText('3.5.13') + await expect(row).toContainText('minor') + + const band = page.locator('.status-band') + await expect(band).toContainText('1 removed') + await expect(band).toContainText('2 bumped') + await expect(band).toContainText('Asked for') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the carried packages are one press away, and the text is one toggle away', async ({ + app, + page +}) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-deps-fold-')) + try { + await openPair(app, page, dir) + await expect(page.locator('.deps-diff')).toBeVisible({ timeout: 20000 }) + + await page.getByTestId('deps-transitive').click() + // @vue/shared bumped and `gone` went — both carried, neither asked for. + await expect(page.locator('.deps-row')).toHaveCount(3) + await expect(page.locator('.deps-row', { hasText: 'gone' })).toBeVisible() + + // The lockfile is still a file: the toggle gives its text back. Through the + // fixture helper, because the toolbar folds View into the overflow at a + // narrow window and hand-rolling the click missed it there. + await setViewOption(page, 'Dependencies', false) + await expect(page.locator('.monaco-diff-editor')).toBeVisible({ timeout: 20000 }) + await expect(page.locator('.deps-diff')).toHaveCount(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/e2e/git-compare.spec.mjs b/e2e/git-compare.spec.mjs new file mode 100644 index 0000000..ba26646 --- /dev/null +++ b/e2e/git-compare.spec.mjs @@ -0,0 +1,96 @@ +import { test, expect, launchApp, freshUserDataDir, firstReadyPage } from './fixtures.mjs' +import { workerEnv } from './workerEnv.mjs' +import { execFileSync } from 'node:child_process' +import { spawn } from 'node:child_process' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MAIN = join(ROOT, 'build', 'main', 'index.js') +const ELECTRON = createRequire(import.meta.url)('electron') + +// Only a real launch proves this: main has to find the repository, read a blob +// out of git, write it somewhere the app may open, and hand the renderer two +// ordinary paths. +function makeRepo() { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-repo-')) + const git = (...args) => + execFileSync('git', args, { + cwd: dir, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@e', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@e' + } + }) + git('init', '-q', '-b', 'main') + writeFileSync(join(dir, 'app.json'), '{\n "replicas": 3\n}\n') + git('add', '.') + git('commit', '-qm', 'first') + writeFileSync(join(dir, 'app.json'), '{\n "replicas": 9\n}\n') + git('add', '.') + git('commit', '-qm', 'second') + return dir +} + +function runCli(userDataDir, cwd, args) { + const env = { ...workerEnv(userDataDir) } + delete env.ELECTRON_RUN_AS_NODE + return new Promise((resolve) => { + const p = spawn(ELECTRON, [MAIN, `--user-data-dir=${userDataDir}`, ...args], { + cwd, + env, + stdio: 'ignore' + }) + p.on('exit', () => resolve()) + setTimeout(resolve, 8000) + }) +} + +test('compares a file against the revision it names', async () => { + const repo = makeRepo() + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runCli(userDataDir, repo, ['compare', 'HEAD~1:app.json', 'app.json']) + + // Both sides arrived: the old one out of git, the new one off disk. + // Named for the revision it came from, so the two sides are told apart. + await expect(page.locator('.slot[data-side="left"] .name')).toContainText('HEAD~1', { + timeout: 20000 + }) + await expect(page.locator('.slot[data-side="right"] .name')).toContainText('app.json') + + // And it is a real comparison of the two revisions, not the file with + // itself: 3 became 9. + const editor = page.locator('.monaco-diff-editor') + await expect(editor).toBeVisible({ timeout: 20000 }) + await expect(editor).toContainText('3') + await expect(editor).toContainText('9') + } finally { + await app.close().catch(() => {}) + rmSync(repo, { recursive: true, force: true }) + } +}) + +test('says so when the revision does not exist, and opens nothing', async () => { + const repo = makeRepo() + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runCli(userDataDir, repo, ['compare', 'no-such-ref:app.json', 'app.json']) + // The refusal is total: a half-loaded comparison would be worse than none. + await page.waitForTimeout(1500) + await expect(page.locator('.slot[data-side="left"] .name')).toHaveCount(0) + } finally { + await app.close().catch(() => {}) + rmSync(repo, { recursive: true, force: true }) + } +}) diff --git a/e2e/merge-resolve.spec.mjs b/e2e/merge-resolve.spec.mjs new file mode 100644 index 0000000..d0cb70b --- /dev/null +++ b/e2e/merge-resolve.spec.mjs @@ -0,0 +1,159 @@ +import { test, expect, launchApp, freshUserDataDir, firstReadyPage } from './fixtures.mjs' +import { workerEnv } from './workerEnv.mjs' +import { execFileSync, spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MAIN = join(ROOT, 'build', 'main', 'index.js') +const ELECTRON = createRequire(import.meta.url)('electron') + +// A conflict git actually produced, resolved through the app, and the file git +// is left holding. Only a real launch proves the whole chain: main reads +// $MERGED, the renderer resolves, and main writes the path it has held since. +function conflictedRepo() { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-merge-')) + const env = { + ...process.env, + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@e', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@e' + } + const git = (...args) => execFileSync('git', args, { cwd: dir, env }) + const file = join(dir, 'app.txt') + git('init', '-q', '-b', 'main') + writeFileSync(file, 'one\nbase\nthree\n') + git('add', '.') + git('commit', '-qm', 'base') + git('checkout', '-qb', 'feature') + writeFileSync(file, 'one\ntheirs\nthree\n') + git('commit', '-qam', 'theirs') + git('checkout', '-q', 'main') + writeFileSync(file, 'one\nours\nthree\n') + git('commit', '-qam', 'ours') + try { + git('merge', 'feature') + } catch { + // Expected: this is the conflict under test. + } + return { dir, file } +} + +function runMergetool(userDataDir, dir, file) { + const env = { ...workerEnv(userDataDir) } + delete env.ELECTRON_RUN_AS_NODE + return new Promise((resolve) => { + const p = spawn( + ELECTRON, + [MAIN, `--user-data-dir=${userDataDir}`, 'mergetool', file, file, file], + { cwd: dir, env, stdio: 'ignore' } + ) + p.on('exit', () => resolve()) + setTimeout(resolve, 8000) + }) +} + +test('resolves a real conflict and writes the merged file back', async () => { + const { dir, file } = conflictedRepo() + expect(readFileSync(file, 'utf8')).toContain('<<<<<<<') + + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runMergetool(userDataDir, dir, file) + + const dialog = page.getByRole('dialog', { name: 'Resolve merge conflicts' }) + await expect(dialog).toBeVisible({ timeout: 20000 }) + + // The two conflicting versions are what the README promises and what the + // dialog sits over — they used to open EMPTY, because the merge route + // returned before vouching for either path. + await expect(page.locator('.slot[data-side="left"] .name')).toContainText('app.txt') + await expect(page.locator('.slot[data-side="right"] .name')).toContainText('app.txt') + + // Nothing may be written while a conflict is undecided. + const save = page.getByTestId('merge-save') + await expect(save).toBeDisabled() + + await page.getByTestId('merge-theirs-0').click() + await expect(save).toBeEnabled() + await save.click() + await expect(dialog).toHaveCount(0, { timeout: 10000 }) + + // The file git is left holding: their side, no markers. + const merged = readFileSync(file, 'utf8') + expect(merged).toBe('one\ntheirs\nthree\n') + expect(merged).not.toContain('<<<<<<<') + } finally { + await app.close().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('declining leaves the file exactly as git left it', async () => { + const { dir, file } = conflictedRepo() + const before = readFileSync(file) + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runMergetool(userDataDir, dir, file) + const dialog = page.getByRole('dialog', { name: 'Resolve merge conflicts' }) + await expect(dialog).toBeVisible({ timeout: 20000 }) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) + + // Untouched, markers and all — the reader said no. + expect(readFileSync(file).equals(before)).toBe(true) + } finally { + await app.close().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + } +}) + +// git calls the mergetool for a BINARY conflict too, and leaves it with no +// markers. Reading it as text turned every invalid byte into U+FFFD and one +// click wrote that over the file. +test('refuses a binary conflict instead of destroying it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-merge-bin-')) + const file = join(dir, 'blob.bin') + const bytes = Buffer.from([0x00, 0x01, 0x02, 0x07, 0xff, 0xfe, 0x00, 0x0a]) + writeFileSync(file, bytes) + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runMergetool(userDataDir, dir, file) + await page.waitForTimeout(1500) + await expect(page.getByRole('dialog', { name: 'Resolve merge conflicts' })).toHaveCount(0) + expect(readFileSync(file).equals(bytes)).toBe(true) + } finally { + await app.close().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + } +}) + +// A file with no markers is not "already resolved" — it is a file this tool has +// no business rewriting. +test('refuses a file with no conflict markers', async () => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-merge-plain-')) + const file = join(dir, 'plain.txt') + writeFileSync(file, 'nothing to resolve\n') + const userDataDir = freshUserDataDir() + const app = await launchApp(userDataDir) + const page = await firstReadyPage(app) + try { + await runMergetool(userDataDir, dir, file) + await page.waitForTimeout(1500) + await expect(page.getByRole('dialog', { name: 'Resolve merge conflicts' })).toHaveCount(0) + expect(readFileSync(file, 'utf8')).toBe('nothing to resolve\n') + } finally { + await app.close().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/scripts/seed-local.mjs b/scripts/seed-local.mjs index 14bf4f4..5dab2e7 100644 --- a/scripts/seed-local.mjs +++ b/scripts/seed-local.mjs @@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { makeXlsx } from './lib/makeXlsx.mjs' import { createIdentityKeys } from '../src/main/sealing.js' @@ -209,7 +209,58 @@ const FILES = { ]) } +// A lockfile pair: thousands of lines of text saying one direct bump, two +// carried packages and one dropped. The dependency view is the only way to see +// that without reading all of it. +const lockPackages = (entries) => ({ + lockfileVersion: 3, + name: 'demo-app', + packages: { + '': { + name: 'demo-app', + dependencies: { vue: '^3.4.0', pinia: '^2.1.0' }, + devDependencies: { vitest: '^4.0.0' } + }, + ...Object.fromEntries( + entries.map(([name, version, dev]) => [ + `node_modules/${name}`, + { + version, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + license: 'MIT', + ...(dev ? { dev: true } : {}) + } + ]) + ) + } +}) + const TEXT_FILES = { + 'lock-before/package-lock.json': JSON.stringify( + lockPackages([ + ['vue', '3.4.21'], + ['pinia', '2.1.7'], + ['vitest', '4.1.9', true], + ['@vue/shared', '3.4.21'], + ['@vue/reactivity', '3.4.21'], + ['nanoid', '3.3.7'], + ['tinybench', '2.9.0', true] + ]), + null, + 2 + ), + 'lock-after/package-lock.json': JSON.stringify( + lockPackages([ + ['vue', '3.5.13'], + ['pinia', '2.1.7'], + ['vitest', '4.1.10', true], + ['@vue/shared', '3.5.13'], + ['@vue/reactivity', '3.5.13'], + ['tinybench', '2.9.0', true] + ]), + null, + 2 + ), 'service-before.yaml': 'service:\n name: diff-engine\n replicas: 3\n features: [a, b]\n', 'service-after.yaml': 'service:\n replicas: 6\n name: diff-engine\n features: [b, a, c]\n', // Two .csv files on disk: the Grid toggle only appears when BOTH sides are @@ -262,7 +313,11 @@ function writeFixtures() { mkdirSync(SEED_DIR, { recursive: true }) for (const [name, bytes] of Object.entries(FILES)) writeFileSync(join(SEED_DIR, name), bytes) for (const [name, text] of Object.entries(TEXT_FILES)) { - writeFileSync(join(SEED_DIR, name), text, 'utf8') + const path = join(SEED_DIR, name) + // A lockfile is recognised by its exact NAME, so the pair has to live in two + // directories the way two checkouts do. + if (name.includes('/')) mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, text, 'utf8') } } diff --git a/scripts/theme-sweep.mjs b/scripts/theme-sweep.mjs index a3cdfad..d5c42b3 100644 --- a/scripts/theme-sweep.mjs +++ b/scripts/theme-sweep.mjs @@ -170,7 +170,46 @@ async function clearComparison(page) { } // Each surface: how to open it, and the pairs that carry meaning once open. +const LOCKFILE = (version) => + JSON.stringify( + { + lockfileVersion: 3, + packages: { + '': { dependencies: { vue: '^3.4.0' } }, + 'node_modules/vue': { version, license: 'MIT' }, + 'node_modules/@vue/shared': { version } + } + }, + null, + 2 + ) + const SURFACES = [ + { + name: 'deps-diff', + // Reached through PASTE mode, because a lockfile is recognised by its NAME + // and paste lets a side be named. Three inks a table cannot settle: the two + // versions are text at the reading floor (the raw --dg-* roles are 3:1 + // non-text and fell to 1.80 on nord), and the row's status keyline is the + // only thing separating a bump from an addition. + open: async (page) => { + await page.getByRole('button', { name: 'Paste mode' }).click() + const names = page.getByPlaceholder('Name this side') + await names.first().fill('package-lock.json') + await names.nth(1).fill('package-lock.json') + await page.getByPlaceholder('Paste original text here').fill(LOCKFILE('3.4.21')) + await page.getByPlaceholder('Paste changed text here').fill(LOCKFILE('3.5.13')) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await page.locator('.deps-row').first().waitFor() + }, + close: (page) => page.getByRole('button', { name: 'Clear', exact: true }).click(), + probes: { + 'package name': ['.deps-name', TEXT], + 'old version': ['.deps-move .del', TEXT], + 'new version': ['.deps-move .add', TEXT], + 'asked-for tag': ['.deps-tag', DIM] + } + }, { name: 'launcher-compose', window: 'launcher', diff --git a/specs/2026-08-09-developer-workflow/plan.md b/specs/2026-08-09-developer-workflow/plan.md new file mode 100644 index 0000000..d36dcc9 --- /dev/null +++ b/specs/2026-08-09-developer-workflow/plan.md @@ -0,0 +1,258 @@ +# Developer workflow — dependencies, revisions, and finishing the merge + +| | | +|---|---| +| **Status** | in-progress | +| **Progress** | 13 / 13 — all three phases shipped | +| **Branch** | `feat/developer-workflow` | +| **Started** | 2026-08-09 | +| **Finished** | — | +| **Bugs found and fixed this iteration** | 0 | +| **Token baseline** | 2026-08-09T20:27:28Z | +| **Claude tokens used** | — | + +Three findings from the developer-experience investigation, in one spec because +they share a thesis and a seam. Each is its own phase, landed in its own commits, +and each is independently shippable — if the spec stops after phase 1, phase 1 is +still a complete feature. + +## Problem + +DiffBro's differentiator is **compare meaning, not lines**: JSON/YAML/XML as data +(`structuralDiff.js`), workbooks as grids with a materiality tolerance, Mermaid as +one picture. That thesis has never been pointed at the three artifacts a developer +actually spends their day on. + +**1 · A lockfile diff is unreadable.** `package-lock.json` in this very repo has +784 package entries. Bumping one dependency rewrites thousands of lines, and every +tool on the market renders that as text and gives up. Nothing tells the reader the +only thing they want to know: *which packages actually changed, which of them I +asked for, and which came along*. + +**2 · The unit of work is wrong.** `diffbro compare` takes file PATHS only +(`cli.js:156`). A developer's unit of work is a change — a commit, a branch, +staged vs working tree — so using DiffBro on your own work means manufacturing two +files first. The tool cannot see the repo it is sitting in. + +**3 · The app advertises a job it cannot finish.** `gitTool.js` registers DiffBro +as git's `difftool` **and** `mergetool`, and its own settings copy admits the +consequence: *"Diff Bro doesn't write the merged file, so git still asks you +whether the merge worked."* `registerArgs` sets `trustExitCode=false` to keep that +honest. So `git mergetool` opens DiffBro, you read the conflict, and then resolve +it somewhere else — a context switch at the worst possible moment, caused by the +app having volunteered for the job. + +## Solution + +Three phases, in ascending order of what they cost and what they risk. + +**Phase 1 — dependencies as dependencies.** A lockfile parser per ecosystem +(`utils/lockfile/`) normalising to one shape, a comparison that classifies each +change (added · removed · bumped · downgraded, direct vs transitive, and the +semver step), and a viewer. It is a **semantic kind** beside tree/grid/diagram — +`semanticKind()` gains `'deps'` — because a lockfile IS json/yaml, so it must not +fight `resolveAdapter`. Zero new dependencies: `yaml` is already in the tree, and +yarn v1/`go.sum` are line formats. + +**Phase 2 — git-native comparison, read-only.** A fenced main-process +`gitRepo.js` that resolves a revision and reads a blob (`git show :`), +plus the CLI grammar for it. No writes, no new dependency, no socket. + +**Phase 3 — finishing the mergetool.** A three-way conflict model, a resolution +UI, and — the line this crosses — main writing the `$MERGED` path git handed it, +then exiting 0 under `trustExitCode=true`. + +| option | why not | +|---|---| +| a lockfile adapter in `resolveAdapter` | a lockfile IS JSON; `textAdapter`/structure already claim it. Making it a semantic KIND keeps the text and structure views one toggle away, which is what a reader wants when the summary is not enough | +| parse lockfiles with an ecosystem library (`@npmcli/arborist`, …) | rule 2. Arborist alone drags a tree far larger than the eleven production dependencies this app has, and it wants the network. The formats are stable and documented; reading them is a parser, not a package manager | +| TOML lockfiles (`Cargo.lock`, `poetry.lock`) in phase 1 | TOML needs a parser this repo does not have, and a hand-rolled one is a new class of bug for two ecosystems. Deferred with the reason recorded, not silently skipped | +| git via `isomorphic-git` | a production dependency reimplementing what is already installed, to avoid a subprocess the app already spawns (`gitTool.js` `execFile`) | +| let the renderer name the git revision and path | rule 3/7. Main owns the repo root, the argv and the fence; the renderer names a revision STRING and never a command | +| three-way merge as a full editor | out. The merged pane accepts a CHOICE per conflict (take left / right / both / neither), not free typing — that is what `git mergetool` needs and it keeps DiffBro a viewer with one deliberate write | + +## Scope + +**In:** + +- **Phase 1** — `utils/lockfile/` (npm v1/v2/v3, pnpm, yarn v1, `go.sum`), + `lockDiff.js`, `canCompareDeps` + `semanticKind() === 'deps'`, a viewer, i18n, + unit + e2e, docs +- **Phase 2** — `src/main/gitRepo.js` (fenced), `git:` IPC, CLI revision grammar, + a UI entry point, docs +- **Phase 3** — `utils/mergeConflicts.js`, the resolution UI, `mergetool` + registration writing `$MERGED`, `trustExitCode=true`, docs + +**Out:** *(recorded, not drifted into)* + +- TOML lockfiles — phase 1 ships four ecosystems, `Cargo.lock`/`poetry.lock` need + a TOML reader and get their own decision +- a package REGISTRY lookup of any kind (latest version, advisories, licences + beyond what the lockfile itself states) — rule 1, and no amount of usefulness + changes that +- staging, committing, or any git WRITE in phase 2 — reading blobs only +- free-text editing of the merged file; DiffBro resolves by choosing hunks +- `git log` browsing / a commit picker UI — phase 2 takes a revision, it does not + become a git client + +## Design + +Phases 1 and 3 add surfaces; phase 2 adds one field and a menu item. + +The dependency view is a **band + rows**, reusing what already exists: the +`.status-band` for the roll-up, `.btn`/`.btn-sm` for its controls, and the same +add/remove/change ink the grid and structure views use (`--success-text`, +`--danger-border`, `--warning-bg` at the same `color-mix` steps). A row's semver +step is a `--chip-h` chip, `.btn-count`-style — the theme's own ink at a fixed +percentage, never `--accent` as a fill under a label. + +The merge view is the existing two-pane frame with a third, read-only result +pane; each conflict is a band carrying four `.btn-sm` choices. No new depth role, +no new shadow level. + +### Theme verdict — all 20 + +Parsed from `styles/themes.css`. Every colour below is already load-bearing +somewhere in the app, so the verdict is about what the new markup composes. + +| theme | ground (`--bg`) | verdict | note | +|---|---|---|---| +| light | `#ffffff` (canvas inverted) | ok | rows sit on `--bg-raised`, the floating-card role | +| dark | `#0d1117` | ok | | +| solar | `#fffdf6` | ok | | +| neon | `#090d18` | ok | accent `#22d3ee` on the focus ring only | +| nord | `#2e3440` | ok | secondary text is `--text-hint`, not `--text-dim` (see ui.css) | +| sepia | `#e9dcbe` | ok | as nord | +| dim | `#1b1917` | ok | | +| beacon | `#000000` | ok | hard keyline `#e0e0e0` — nothing here removes a border | +| meridian | `#f5f7f4` | ok | | +| linen | `#faf7f0` | ok | | +| bloom | `#f9f4f5` | ok | | +| nyan | `#160a20` | ok | accent `#ff2ecb` — no glow, no accent fill under a label | +| matrix | `#020a04` | ok | accent `#00ff41` — same | +| contrast | `#ffffff` | ok | hard keyline `#111111`, kept | +| volcano | `#000000` | ok | border `#ffc9a4` | +| amber | `#0f0a02` | ok | | +| tide | `#0b1a1e` | ok | | +| ember | `#1a1013` | ok | | +| graphite | `#161616` | ok | achromatic; status ink is the semantic tokens, not hues | +| vector | `#ffffff` | ok | | + +Read off real frames per phase before that phase's commits close, the way the +row-hover mark was — a table alone is not the check. + +## Security rules touched + +Phase 1 touches none: pure parsing of a file already read, rendered through Vue +text interpolation. Rule 6 still applies to the parse — a lockfile is untrusted +input, so entry counts are capped and a malformed file degrades to the text view +rather than throwing. + +**Phase 2 and 3 are the ones that need care.** + +- **Rule 1 (offline) holds.** `git show` opens no socket. It is the same class of + sandbox exit as `gitTool.js`'s existing `execFile('git', …)` and the Windows + clipboard `powershell.exe` call — a subprocess, not a network client. Any git + subcommand that can reach the network (`fetch`, `pull`, `clone`, `ls-remote`) + is refused by an allowlist, not by convention. +- **Rule 7 (leaving the sandbox is fenced in main).** The fence, stated up front: + fixed argv through `execFile`, **never a shell**; the repo root computed in + MAIN via `git rev-parse --show-toplevel` and never accepted from the renderer; + `--` before every path; and the invocation hardened against a hostile + repository — `-c core.fsmonitor=` and `-c core.hooksPath=/dev/null`, with + `GIT_CONFIG_NOSYSTEM=1` and a cleared `GIT_*` environment. A repo you cloned is + untrusted input, and repo-local config has been an execution vector before. + This is the phase-2 acceptance criterion, not a nicety. +- **Rule 6.** A revision string from the renderer is validated against a + conservative pattern before it reaches argv, and never begins with `-`. +- **The write in phase 3 is the deliberate line-crossing.** It is narrow: main + writes exactly the `$MERGED` path git passed on the command line, held in main + from launch, never round-tripped through the renderer, and only on an explicit + user action. The renderer sends the resolved TEXT, not a path — the same shape + as `clipboard:writeFile`, which takes bytes and a display name and refuses to + let the renderer name a file. + +## Test plan + +- **unit** — `tests/renderer/utils/lockfile/*.test.js`: each parser against a real + fixture of its format, including a malformed one; `lockDiff.test.js`: added, + removed, bumped, downgraded, direct vs transitive, and a bump that changes only + `resolved` +- **unit** — `tests/main/gitRepo.test.js`: the argv builder (the fence is a pure + function, so it is unit-testable without a repo), revision validation, and the + refusal of every network subcommand +- **unit** — `tests/renderer/utils/mergeConflicts.test.js`: conflict-region + parsing, each of the four resolutions, a file with no conflicts, nested markers +- **e2e** — `e2e/deps.spec.mjs`: two real lockfiles open as a dependency summary + and the text view is one toggle away; `e2e/git-compare.spec.mjs`: a temp repo + with two commits compared by revision; `e2e/merge-resolve.spec.mjs`: a real + `git mergetool` invocation resolved and written +- **red → green** — every fix in this spec, recorded with its failure +- **seed fixtures** — `seed-local.mjs` gains a `package-lock` before/after pair, + so the dependency view is openable by hand on the host + +## Docs impact + +| surface | needed? | what changes | +|---|---|---| +| `README.md` | **yes** | a Dependencies row, the git entry point, and the Terminal row's grammar; the mergetool sentence stops apologising | +| `docs/screenshots/*.png` | **yes, phase 1 and 3** | a new viewer is a new frame; recapture in the container | +| `docs/roadmap.md` | **yes** | a Developer workflow track; "Comparing more" loses three-way merge to it | +| `docs/brand/roadmap.svg` | **yes** | same move, hand-authored | +| `docs/security.md` | **yes, phase 2** | the git subprocess fence belongs beside the other sandbox exits | +| `docs/ipc-security.md` | **yes, phase 2/3** | two new IPC surfaces | +| `docs/glossary.md` | **yes** | lockfile, direct vs transitive, three-way merge | + +## Implementation plan + +**Phase 1 — dependencies** + +- [x] 1. `utils/lockfile/` — npm v1/v2/v3, pnpm, yarn v1, `go.sum` → one shape +- [x] 2. `utils/lockDiff.js` — classify each change; direct vs transitive +- [x] 3. `canCompareDeps`, `semanticKind() === 'deps'`, `shouldOpenSemantic` +- [x] 4. The viewer + its stylesheet + i18n +- [x] 5. Seed pair, e2e, README/glossary + +**Phase 2 — git-native comparison** + +- [x] 6. `src/main/gitRepo.js` — the fence, argv builders, revision validation +- [x] 7. IPC + CLI grammar, wired end to end through `parseCompare` and the launch +- [x] 8. `docs/security.md` / `docs/ipc-security.md` + README +- [x] 9. e2e against a temp repo + +**Phase 3 — finishing the merge** + +- [x] 10. `utils/mergeConflicts.js` — regions and the four resolutions +- [x] 11. The merge view +- [x] 12. `mergetool` registration writing `$MERGED`, `trustExitCode=true` +- [x] 13. e2e through a real `git mergetool`, docs, roadmap + SVG + +## Decisions + +| date | decision | why | rejected | +|---|---|---|---| +| 2026-08-09 | all three findings in ONE spec, three phases, each in its own commits | the user asked for it; they share the "compare meaning" thesis and the semantic-kind seam, and each is independently shippable | three specs | +| 2026-08-09 | order is lockfiles → git read → merge write | ascending risk. Phase 1 strains no rule, phase 2 opens a fenced subprocess, phase 3 crosses the never-writes line. Each phase's evidence informs the next | merge first, which is the loudest gap but the riskiest start | +| 2026-08-09 | **phase 3 crosses "DiffBro never writes files", on the user's instruction** | `docs/roadmap.md` parks three-way merge as "a decision, not code". The user made the decision on 2026-08-09. The argument that carries it: the app ALREADY registered as `git mergetool`, so it already took the job | keeping the viewer pure and de-registering as a mergetool instead — the honest alternative, not chosen | +| 2026-08-09 | dependencies are a semantic KIND, not an adapter | a lockfile is JSON; making it a kind keeps text and structure one toggle away | a `resolveAdapter` entry, which would have to out-rank `textAdapter` and hide the raw file | +| 2026-08-09 | four ecosystems in phase 1, TOML deferred | no new dependency, and the four cover npm/pnpm/yarn/go | hand-rolling TOML | + +## Validation + +- [ ] `/validate` — summary below, full report in `quality-audit.md` +- [ ] `npm run check` — real output, per phase +- [ ] every phase seen running before its commits close +- [ ] every Docs-impact "yes" done +- [ ] token usage measured + +### Token usage + +| category | tokens | +|---|---:| +| input | | +| output | | +| cache write | | +| cache read | | +| **total** | | + +**Outcome:** diff --git a/src/main/cli.js b/src/main/cli.js index 9ceb136..66f25c7 100644 --- a/src/main/cli.js +++ b/src/main/cli.js @@ -1,4 +1,5 @@ import { COMMANDS } from '../shared/cliCommands' +import { splitRevisionArg } from './gitRevisionArg' import { t } from './i18n' // The `diffbro` terminal command. A second launch never becomes a second app: // Electron's single-instance lock hands its argv to the running one, which is @@ -114,6 +115,15 @@ export function parseNewSnippet(words) { const VERBS = { compare: (rest, resolve) => parseCompare(rest, resolve), difftool: (rest, resolve) => parseCompare(rest, resolve, true), + // git hands a mergetool four paths in a fixed order: LOCAL REMOTE MERGED + // BASE. MERGED is the file in the repo — the one with the markers in it, and + // the one this run may write back. + mergetool: (rest, resolve) => { + const paths = rest.filter((p) => p.trim()).map(resolve) + if (paths.length < 3) return { command: null, error: 'mergetool needs LOCAL REMOTE MERGED.' } + const [local, remote, merged] = paths + return { command: { name: 'merge', local, remote, merged }, error: null } + }, open: (rest, resolve) => parseOpen(rest, resolve), backup: (rest, resolve) => parseBackup(rest, resolve), // One verb. `--interactive` asks in the terminal and saves; without it the @@ -148,12 +158,20 @@ function parseBackup(rest, resolve) { return { command: { name: 'backup', path: resolve(paths[0]) }, error: null } } +// A side is either a path to resolve against the shell's cwd, or the +// `revision:path` pair naming a file inside the repository as it stood then — +// which main reads later, because only main may talk to git. +const sideOf = (word, resolve) => splitRevisionArg(word) ?? resolve(word) + function parseCompare(rest, resolve, transient = false) { // An empty word is not a path — resolving it would silently mean the cwd. const paths = rest.filter((p) => p.trim()) if (!paths.length) return { command: null, error: 'compare needs a file path.' } if (paths.length > 2) return { command: null, error: 'compare takes at most two files.' } - return { command: { name: 'compare', files: paths.map(resolve), transient }, error: null } + return { + command: { name: 'compare', files: paths.map((p) => sideOf(p, resolve)), transient }, + error: null + } } /** diff --git a/src/main/cliRoute.js b/src/main/cliRoute.js index 8001394..38adf94 100644 --- a/src/main/cliRoute.js +++ b/src/main/cliRoute.js @@ -10,6 +10,10 @@ import { installShim, removeShim, shimStatus } from './cliShim' import { gitToolStatus, registerGitTool, sweepGitTemp, unregisterGitTool } from './gitTool' import { ensureMainWindow } from './quickLook' import { allowCliPath } from './files' +import { fileAtRevision, isRevisionSide, REVISION_ERROR_KEYS } from './gitCliFiles' +import { beginMerge, cancelMerge, writeMerged } from './mergeSession' +import { readFileSync } from 'node:fs' +import { hasConflictMarkers, isBinaryBuffer } from './mergeGuards' import { t } from './i18n' // A command can arrive before any window exists (a cold `diffbro compare …`), @@ -43,19 +47,91 @@ export function routeCliArgv(argv, cwd, carried = null) { process.stderr.write(`${parsed.error}\n`) return } - routeCommand(parsed.command) + routeCommand(parsed.command, cwd) } -function routeCommand(command) { +// Each `revision:path` side becomes a real file before the renderer hears about +// the command at all, so everything downstream sees paths and nothing else has +// to know git was involved. +async function withRevisionsResolved(command, cwd) { + const files = [] + for (const side of command.files) { + if (!isRevisionSide(side)) { + files.push(side) + continue + } + const res = await fileAtRevision(side, cwd || process.cwd()) + if (res.error) { + process.stderr.write(`${t(REVISION_ERROR_KEYS[res.error] ?? 'cliErrors.not-a-repo')}\n`) + return null + } + files.push(res.file) + } + return { ...command, files } +} + +function deliverResolved(command, cwd) { + withRevisionsResolved(command, cwd) + .then((ready) => { + if (!ready) return + ready.files.forEach(allowCliPath) + deliver(ready) + }) + // A path the fence refuses THROWS. Without this it opened nothing, said + // nothing, and landed in the crash log instead. + .catch(() => process.stderr.write(`${t('cliErrors.refused')}\n`)) +} + +const needsGit = (command) => command?.name === 'compare' && command.files.some(isRevisionSide) + +// A mergetool launch: main REMEMBERS the path git wants written and sends the +// renderer the conflicted text, never the path. What comes back is text. +function routeMerge(command) { + let buffer + try { + buffer = readFileSync(command.merged) + } catch { + process.stderr.write(`${t('cliErrors.merge-unreadable')}\n`) + return + } + // git calls the mergetool for a BINARY conflict too, and leaves it with no + // markers. Decoding one as text turns every invalid byte into U+FFFD, and + // writing that back destroys the file — so it never reaches the renderer. + if (isBinaryBuffer(buffer)) { + process.stderr.write(`${t('cliErrors.merge-binary')}\n`) + return + } + const content = buffer.toString('utf8') + // Nothing to decide is not the same as "resolved": a file with no markers is + // one this tool has no business rewriting. + if (!hasConflictMarkers(content)) { + process.stderr.write(`${t('cliErrors.merge-no-conflicts')}\n`) + return + } + beginMerge(command) + // Both sides are files main just vouched for; without this file:read refuses + // them and the two panes open empty. + allowCliPath(command.local) + allowCliPath(command.remote) + deliver({ name: 'merge', local: command.local, remote: command.remote, content }) +} + +// The verbs that need something done before the renderer hears about them. +const SPECIAL = { // `open` with no file has nothing to tell the renderer — the window IS the // answer, so it never reaches deliver's pending queue. - if (command?.name === 'raise') return void ensureMainWindow()?.focus() + raise: () => void ensureMainWindow()?.focus(), + merge: (command) => routeMerge(command), + 'clipboard-save': (command) => deliver({ ...command, text: clipboard.readText() }) +} + +function routeCommand(command, cwd) { + if (needsGit(command)) return void deliverResolved(command, cwd) + const special = SPECIAL[command?.name] + if (special) return void special(command) // Vouch for the paths before the renderer asks for them: file:read honours // only what main has already approved. if (command?.name === 'compare') command.files.forEach(allowCliPath) - if (command?.name === 'clipboard-save') { - return void deliver({ ...command, text: clipboard.readText() }) - } deliver(command) } @@ -92,6 +168,11 @@ export function registerCliIpc() { }) ).response === 1 + // The renderer's ONLY say in the merge is the text. It cannot name a file: + // main has held that path since the launch. + ipcMain.handle('merge:write', (e, text) => writeMerged(text)) + // Declining is an answer too: it releases the launcher and spends the session. + ipcMain.handle('merge:cancel', () => cancelMerge()) ipcMain.handle('cli:status', () => shimStatus(where())) ipcMain.handle('cli:install', async () => (await confirmed(t('dialog.cliInstall.message'), t('dialog.cliInstall.detail'))) diff --git a/src/main/gitCliFiles.js b/src/main/gitCliFiles.js new file mode 100644 index 0000000..f4fdb77 --- /dev/null +++ b/src/main/gitCliFiles.js @@ -0,0 +1,46 @@ +// A `revision:path` side of a `diffbro compare`, read straight out of the +// repository. Only MAIN talks to git. +// +// The content is handed over as a loaded FILE, not staged on disk: a temp copy +// has to live somewhere the renderer is allowed to read, which `file:read` +// refuses under userData — and it would then outlive the comparison with +// nothing to tell it the tab had closed. +import { basename } from 'node:path' +import { readBlobArgs, resolveRevisionArgs, repoRootArgs, runGitIn } from './gitRepo' + +/** + * @param {{revision: string, relPath: string}} side + * @param {string} cwd the shell's working directory + * @returns {Promise<{file: {path: null, name: string, content: string}}|{error: string}>} + */ +export async function fileAtRevision(side, cwd) { + const root = await runGitIn(repoRootArgs(), cwd) + if (!root.ok || !root.stdout.trim()) return { error: 'not-a-repo' } + const dir = root.stdout.trim() + const resolved = await runGitIn(resolveRevisionArgs(side.revision), dir) + if (!resolved.ok) return { error: 'no-such-revision' } + const blob = await runGitIn(readBlobArgs(side.revision, side.relPath), dir) + if (!blob.ok) return { error: 'not-in-revision' } + return { + // Named for the revision it came from, so two sides of the same file are + // told apart in the slot and the tab title. + file: { + path: null, + name: `${basename(side.relPath) || 'file'} @ ${side.revision}`, + content: blob.stdout + } + } +} + +/** + * Literal key ids, not a key built from the error at the call site: an assembled + * key is invisible to check:i18n and cannot be found when it goes stale. + */ +export const REVISION_ERROR_KEYS = { + 'not-a-repo': 'cliErrors.not-a-repo', + 'no-such-revision': 'cliErrors.no-such-revision', + 'not-in-revision': 'cliErrors.not-in-revision' +} + +/** Whether a compare side still has to be fetched out of the repository. */ +export const isRevisionSide = (side) => !!side && typeof side === 'object' && !!side.revision diff --git a/src/main/gitMergeTool.js b/src/main/gitMergeTool.js new file mode 100644 index 0000000..821cf4b --- /dev/null +++ b/src/main/gitMergeTool.js @@ -0,0 +1,41 @@ +// The mergetool launcher, which differs from the difftool one in the only way +// that matters: it WAITS. +// +// The app is single-instance, so a launch returns as soon as the running window +// has been told. If the script exited there, git would read the merge as +// finished before the reader had chosen anything, and trustExitCode=true would +// be a lie. Polling $MERGED's modification time is what makes it true. +import { MARK } from './gitToolMark' +import { shQuote } from './shellQuote' + +// Two hours: longer than any real conflict takes, short enough that a window +// closed and forgotten does not hold a terminal for ever. +const WAIT_SECONDS = 7200 + +/** Beside the difftool launcher, so removing either leaves the other alone. */ +export function gitMergeTarget(target) { + return `${target}-merge` +} + +/** @param {string} exePath the installed app binary */ +export function gitMergeScript(exePath, entryPath = null) { + return `#!/bin/sh +${MARK} +done_file="$3.diffbro-merge-done" +rm -f "$done_file" +${shQuote(exePath)}${entryPath ? ` ${shQuote(entryPath)}` : ''} mergetool "$1" "$2" "$3" || exit 1 +# Wait for the reader. Exiting here would tell git the merge was resolved before +# anyone had looked at it. The bound is what stops a closed window hanging the +# terminal for ever. +waited=0 +while [ ! -f "$done_file" ] && [ "$waited" -lt ${WAIT_SECONDS} ]; do + sleep 1 + waited=$((waited + 1)) +done +if [ ! -f "$done_file" ]; then exit 1; fi +verdict=$(cat "$done_file" 2>/dev/null) +rm -f "$done_file" +[ "$verdict" = "written" ] || exit 1 +exit 0 +` +} diff --git a/src/main/gitRepo.js b/src/main/gitRepo.js new file mode 100644 index 0000000..0e484fd --- /dev/null +++ b/src/main/gitRepo.js @@ -0,0 +1,118 @@ +// Reading a file out of a git revision. READ ONLY: `rev-parse` and `show` are +// the whole vocabulary, so nothing here can ask git to reach the network. +// +// The fence is in docs/security.md; what is not obvious from the code is why +// each hardening flag is there, so those are noted at the constant itself. +import { execFile } from 'node:child_process' + +/** + * Applied to EVERY invocation. A repo's own config must not be able to run + * anything: hooks are pointed at nothing, the filesystem monitor — which is a + * command git will spawn — is emptied, and no pager is launched. + */ +export const HARDENING = Object.freeze([ + '--no-pager', + '-c', + 'core.hooksPath=', + '-c', + 'core.fsmonitor=', + '-c', + 'core.editor=true', + '-c', + 'protocol.ext.allow=never' +]) + +// Deliberately narrower than git's own rules: the shapes a reader types, and +// nothing that could be read as an option or reach a shell. +const REVISION = /^[A-Za-z0-9][A-Za-z0-9._/@{}^~-]*$/ +const MAX_REVISION = 255 + +/** @param {unknown} rev */ +export function isSafeRevision(rev) { + if (typeof rev !== 'string' || !rev || rev.length > MAX_REVISION) return false + if (rev.startsWith('-')) return false + return REVISION.test(rev) +} + +// git speaks posix paths in a `rev:path` pair whatever the platform, and a path +// that climbs out of the repository is not a path in it. +function repoPath(path) { + const posix = String(path ?? '').replace(/\\/g, '/') + if (!posix || posix.startsWith('/') || posix.startsWith('-')) return null + if (posix.split('/').includes('..')) return null + return posix +} + +function checkedRevision(rev) { + if (!isSafeRevision(rev)) throw new Error('unsafe-revision') + return rev +} + +/** Where the repository containing the working directory begins. */ +export function repoRootArgs() { + return [...HARDENING, 'rev-parse', '--show-toplevel'] +} + +/** Resolve a revision to the commit it names, or fail. */ +export function resolveRevisionArgs(rev) { + return [ + ...HARDENING, + 'rev-parse', + '--verify', + '--end-of-options', + `${checkedRevision(rev)}^{commit}` + ] +} + +/** Read one file as it stood at a revision. */ +export function readBlobArgs(rev, path) { + const safe = repoPath(path) + if (!safe) throw new Error('unsafe-path') + return [...HARDENING, 'show', '--end-of-options', `${checkedRevision(rev)}:${safe}`] +} + +/** + * The environment a git invocation gets. Every inherited GIT_* is DROPPED: + * GIT_DIR, GIT_INDEX_FILE and GIT_ALTERNATE_OBJECT_DIRECTORIES each redirect git + * at something other than the repository main chose, and inheriting them hands + * that redirect to whoever launched the app. + * @param {Record} [base] + */ +export function gitEnv(base = process.env) { + const env = {} + for (const [key, value] of Object.entries(base)) { + if (!key.startsWith('GIT_')) env[key] = value + } + env.GIT_CONFIG_NOSYSTEM = '1' + env.GIT_TERMINAL_PROMPT = '0' + return env +} + +/** + * One git invocation, in a directory MAIN chose. Resolves rather than rejects so + * a missing revision is an answer, not a crash. + * @param {string[]} args one of the vectors above — never assembled by a caller + * @param {string} cwd + * @returns {Promise<{ok: boolean, stdout: string, error: string}>} + */ +export function runGitIn(args, cwd) { + return new Promise((resolve) => { + execFile( + 'git', + args, + { + cwd, + windowsHide: true, + maxBuffer: 64 * 1024 * 1024, + env: gitEnv() + }, + (err, stdout, stderr) => { + resolve({ + ok: !err, + stdout: String(stdout ?? ''), + error: err ? String(stderr ?? err.message).trim() : '' + }) + } + ) + }) +} diff --git a/src/main/gitRevisionArg.js b/src/main/gitRevisionArg.js new file mode 100644 index 0000000..403c562 --- /dev/null +++ b/src/main/gitRevisionArg.js @@ -0,0 +1,22 @@ +// `HEAD~1:src/a.js` — the `revision:path` shape git itself uses, as a CLI +// argument. Pure, so the grammar is testable without a repository. +import { isSafeRevision } from './gitRepo' + +// A single letter before the colon is a Windows drive, not a revision: `C:/x` +// must stay a path. +const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/ + +/** + * @param {unknown} arg + * @returns {{revision: string, relPath: string}|null} null when the argument is + * an ordinary path, which is most of them. + */ +export function splitRevisionArg(arg) { + if (typeof arg !== 'string' || WINDOWS_DRIVE.test(arg)) return null + const at = arg.indexOf(':') + if (at <= 0 || at === arg.length - 1) return null + const revision = arg.slice(0, at) + const relPath = arg.slice(at + 1) + // The path half keeps any further colons; only the FIRST one separates. + return isSafeRevision(revision) && relPath ? { revision, relPath } : null +} diff --git a/src/main/gitTool.js b/src/main/gitTool.js index 4a92859..ee35c3a 100644 --- a/src/main/gitTool.js +++ b/src/main/gitTool.js @@ -19,9 +19,10 @@ import { import { dirname, join, posix, win32 } from 'node:path' import { execFile } from 'node:child_process' import { shQuote } from './shellQuote' +import { gitMergeScript, gitMergeTarget } from './gitMergeTool' +import { MARK } from './gitToolMark' export const GIT_TOOL_NAME = 'diffbro' -const MARK = '# diff-bro git tool' /** * Where the launcher lives — beside the `diffbro` shim, so removing either @@ -70,18 +71,19 @@ const invocation = (script) => `"${script}" "$LOCAL" "$REMOTE" "$MERGED"` /** * The registration, as git config argument vectors. * - * Diff Bro shows a merge's two conflicting sides for READING; it never writes - * $MERGED. trustExitCode=false is what keeps that honest — git asks whether the - * merge succeeded instead of taking a clean exit as "resolved". + * Diff Bro shows a merge's two conflicting sides AND resolves them: the merge + * launcher waits for $MERGED to be written, so trustExitCode=true is honest. * @param {string} script * @returns {string[][]} */ -export function registerArgs(script) { +export function registerArgs(script, mergeScript = script) { const cmd = invocation(script) return [ ['config', '--global', `difftool.${GIT_TOOL_NAME}.cmd`, cmd], - ['config', '--global', `mergetool.${GIT_TOOL_NAME}.cmd`, cmd], - ['config', '--global', `mergetool.${GIT_TOOL_NAME}.trustExitCode`, 'false'], + ['config', '--global', `mergetool.${GIT_TOOL_NAME}.cmd`, invocation(mergeScript)], + // The merge script waits for $MERGED to change before it exits, so a clean + // exit now MEANS resolved and git can be told to believe it. + ['config', '--global', `mergetool.${GIT_TOOL_NAME}.trustExitCode`, 'true'], ['config', '--global', 'diff.tool', GIT_TOOL_NAME], ['config', '--global', 'merge.tool', GIT_TOOL_NAME] ] @@ -117,6 +119,9 @@ export function runGit(args) { }) } +// Somebody else's file of the same name — theirs to keep. +const occupied = (file) => existsSync(file) && !looksLikeOurs(file) + const looksLikeOurs = (file) => { try { return readFileSync(file, 'utf8').includes(MARK) @@ -148,18 +153,23 @@ export async function gitToolStatus({ home, platform, localAppData, git = runGit export async function registerGitTool({ exePath, home, platform, localAppData, entryPath, git }) { git = git ?? runGit const target = gitToolTarget({ platform, home, localAppData }) + const mergeTarget = gitMergeTarget(gitToolTarget({ platform, home, localAppData })) if (!(await git(['--version'])).ok) return { ok: false, error: 'git is not on your PATH.' } try { - if (existsSync(target) && !looksLikeOurs(target)) { + if (occupied(target) || occupied(mergeTarget)) { return { ok: false, error: 'A different diffbro-git exists there.' } } mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, gitToolScript(exePath, entryPath), 'utf8') - if (platform !== 'win32' && process.platform !== 'win32') chmodSync(target, 0o755) + writeFileSync(mergeTarget, gitMergeScript(exePath, entryPath), 'utf8') + if (platform !== 'win32' && process.platform !== 'win32') { + chmodSync(target, 0o755) + chmodSync(mergeTarget, 0o755) + } } catch (e) { return { ok: false, error: e.message } } - for (const args of registerArgs(target)) { + for (const args of registerArgs(target, mergeTarget)) { const res = await git(args) if (!res.ok) return { ok: false, error: 'git refused the configuration.' } } @@ -177,8 +187,10 @@ export async function unregisterGitTool({ home, platform, localAppData, git = ru await git(args) } const target = gitToolTarget({ platform, home, localAppData }) + const mergeTarget = gitMergeTarget(gitToolTarget({ platform, home, localAppData })) try { if (!existsSync(target) || looksLikeOurs(target)) rmSync(target, { force: true }) + if (!existsSync(mergeTarget) || looksLikeOurs(mergeTarget)) rmSync(mergeTarget, { force: true }) } catch (e) { return { ok: false, error: e.message } } diff --git a/src/main/gitToolMark.js b/src/main/gitToolMark.js new file mode 100644 index 0000000..596ace8 --- /dev/null +++ b/src/main/gitToolMark.js @@ -0,0 +1,4 @@ +// The line that marks a launcher script as ours, so removing one never deletes +// somebody else's file of the same name. Its own module because BOTH launchers +// need it and neither should have to import the other. +export const MARK = '# diff-bro git tool' diff --git a/src/main/mergeGuards.js b/src/main/mergeGuards.js new file mode 100644 index 0000000..2813e29 --- /dev/null +++ b/src/main/mergeGuards.js @@ -0,0 +1,24 @@ +// What a mergetool run refuses before the renderer ever sees it. Pure, so both +// refusals are testable without a repository. + +/** + * A NUL byte in the first 8 KB means this is not text — the same sniff + * files.js uses. git calls the mergetool for a binary conflict as readily as a + * text one, and decoding one as UTF-8 replaces every invalid byte, so writing + * it back would destroy the file. + */ +export function isBinaryBuffer(buffer) { + return buffer.subarray(0, 8192).includes(0) +} + +// Seven characters at the start of a line, then end-of-line or a space. +const OPENS = /^<<<<<<<(?: |$)/m + +/** + * Whether the file git handed over actually holds a conflict. Without this a + * file with none reads as "nothing left to decide", which enables Save and + * writes it straight back — telling git a merge succeeded that never happened. + */ +export function hasConflictMarkers(text) { + return OPENS.test(String(text ?? '')) +} diff --git a/src/main/mergeSession.js b/src/main/mergeSession.js new file mode 100644 index 0000000..48f69af --- /dev/null +++ b/src/main/mergeSession.js @@ -0,0 +1,69 @@ +// The one place Diff Bro writes a file it did not create: the `$MERGED` path +// git handed it. Main holds that path from launch and the renderer sends only +// TEXT, so there is no argument through which it could name a file. +import { writeFileSync } from 'node:fs' + +let pending = null + +/** + * The file the launcher watches. Comparing $MERGED's own size and timestamp was + * not enough: a resolution that writes the same bytes back changes neither, and + * `ls -l` only resolves to the minute — either way the launcher waited forever. + * A sentinel appears exactly once, whatever the resolution turned out to be. + */ +export const doneSentinel = (merged) => `${merged}.diffbro-merge-done` + +/** Remembered from the launch argv, never from a message. */ +export function beginMerge({ merged, local, remote }) { + pending = { merged, local, remote } + return pending +} + +export function mergeInProgress() { + return pending +} + +/** + * The reader declined. The session is spent — an abandoned launch must not leave + * a path armed for the life of the process — and the launcher is released so + * `git mergetool` stops waiting on a decision that is not coming. + */ +export function cancelMerge() { + if (!pending) return { ok: false } + const { merged } = pending + pending = null + try { + writeFileSync(doneSentinel(merged), 'cancelled', 'utf8') + } catch { + // The launcher times out on its own; a sentinel it cannot read is not fatal. + } + return { ok: true } +} + +export function endMerge() { + pending = null +} + +/** + * Write the resolved text to the path this session was launched with. + * @param {unknown} text the composed file — the renderer's only say in this + * @returns {{ok: true, path: string}|{ok: false, error: string}} + */ +export function writeMerged(text) { + if (!pending) return { ok: false, error: 'no-merge' } + if (typeof text !== 'string') return { ok: false, error: 'not-text' } + try { + writeFileSync(pending.merged, text, 'utf8') + } catch (err) { + return { ok: false, error: String(err?.message ?? err) } + } + const path = pending.merged + endMerge() + try { + writeFileSync(doneSentinel(path), 'written', 'utf8') + } catch { + // Falling back to the launcher's own timeout is better than failing a write + // that already landed. + } + return { ok: true, path } +} diff --git a/src/preload/index.js b/src/preload/index.js index 4d2e89f..9f4d5dc 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -4,6 +4,8 @@ contextBridge.exposeInMainWorld('api', { openFile: (side, format) => ipcRenderer.invoke('file:open', side, format), readClipboardFiles: () => ipcRenderer.invoke('clipboard:readFiles'), readFile: (path, opts) => ipcRenderer.invoke('file:read', path, opts), + writeMerged: (text) => ipcRenderer.invoke('merge:write', text), + cancelMerge: () => ipcRenderer.invoke('merge:cancel'), // `format` names a row of main's own export table; never an extension. exportDiffFile: (payload) => ipcRenderer.invoke('diff:exportFile', payload), // Streamed comparison: files too large to hold are indexed by line in main diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index b052c8f..5c65377 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -15,6 +15,7 @@ import FileSlot from './components/FileSlot.vue' import DiffViewer from './components/DiffViewer.vue' import SpreadsheetDiffViewer from './components/SpreadsheetDiffViewer.vue' import DiagramDiffViewer from './components/DiagramDiffViewer.vue' +import DepsDiffViewer from './components/DepsDiffViewer.vue' import StructureDiffViewer from './components/StructureDiffViewer.vue' import StreamedDiffViewer from './components/StreamedDiffViewer.vue' import SupportedFormats from './components/SupportedFormats.vue' @@ -172,6 +173,7 @@ useSnippetDiffSync() + diff --git a/src/renderer/src/components/AppDialogs.vue b/src/renderer/src/components/AppDialogs.vue index fd0a5db..d3142a2 100644 --- a/src/renderer/src/components/AppDialogs.vue +++ b/src/renderer/src/components/AppDialogs.vue @@ -51,6 +51,7 @@ const paste = usePasteToCompareStore() const snippets = useSnippetStore() const vault = useVaultStore() const errors = useErrorStore() +import { MergeDialog } from '../features/merge' diff --git a/src/renderer/src/components/DepsDiffViewer.vue b/src/renderer/src/components/DepsDiffViewer.vue new file mode 100644 index 0000000..9b673fb --- /dev/null +++ b/src/renderer/src/components/DepsDiffViewer.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/renderer/src/components/DepsStatusBand.vue b/src/renderer/src/components/DepsStatusBand.vue new file mode 100644 index 0000000..3e89065 --- /dev/null +++ b/src/renderer/src/components/DepsStatusBand.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/renderer/src/components/styles/DepsDiffViewer.css b/src/renderer/src/components/styles/DepsDiffViewer.css new file mode 100644 index 0000000..88147db --- /dev/null +++ b/src/renderer/src/components/styles/DepsDiffViewer.css @@ -0,0 +1,117 @@ +/* The dependency comparison. Every colour is a role the diff panes already use, + so it reads the same on all twenty themes and keeps the hard keyline the + contrast/beacon palettes rely on. The band itself is .status-band in ui.css. */ +.deps-diff { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-raised); + overflow: hidden; +} +.deps-bar { + flex: none; + gap: var(--space-3); + padding: 0 var(--space-3); + min-height: var(--control-h); + background: var(--bg-panel); + border-bottom: 1px solid var(--border); +} +.deps-title { + color: var(--text-dim); + font-size: var(--font-sm); + font-weight: 600; +} +.deps-bar .btn { + margin-left: auto; +} +.identical-row { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + color: var(--text-dim); +} +.identical-row .ok { + color: var(--success-text); +} +.deps-rows { + flex: 1; + min-height: 0; + overflow: auto; +} +/* A row is a band: its height comes from the control scale, never from padding, + so a longer package name cannot make one row taller than its neighbours. */ +.deps-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); + font-size: var(--font-md); +} +.deps-row:last-child { + border-bottom: 0; +} +/* Status is on the LEFT EDGE rather than as a wash: a row already carries two + coloured versions, and a third tint behind them reads as neither. */ +.deps-row.added { + box-shadow: inset 3px 0 0 var(--dg-add); +} +.deps-row.removed { + box-shadow: inset 3px 0 0 var(--dg-del); +} +/* --warning-bg is a BACKGROUND role: as a keyline it fell to 1.35 on contrast + and under the 3.0 non-text floor on every light ground. --dg-chg is the + change role the diff panes use, and it is held to a floor on all 20. */ +.deps-row.bumped, +.deps-row.downgraded { + box-shadow: inset 3px 0 0 var(--dg-chg); +} +.deps-name { + font-family: var(--font-mono); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.deps-move { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--font-sm); + white-space: nowrap; +} +/* A version is TEXT a reader reads, so it needs the 4.5 floor — the raw status + tokens are 3:1 non-text roles and fell to 1.80 on nord. Mixing toward the + theme's own ink is what .status-band already does, for exactly this reason. */ +.deps-move .add { + color: color-mix(in srgb, var(--dg-add) 55%, var(--text)); + font-weight: 600; +} +.deps-move .del { + color: color-mix(in srgb, var(--dg-del) 55%, var(--text)); + font-weight: 600; +} +/* Height from --chip-h, never from padding — the rule the Esc chip broke. */ +.deps-tag { + height: var(--chip-h); + padding: 0 var(--space-2); + display: inline-flex; + align-items: center; + border-radius: var(--radius-pill); + background: var(--btn-face); + color: var(--text-hint); + font-size: var(--font-xs); + white-space: nowrap; +} +.deps-step { + flex: none; +} +.deps-license { + color: var(--text-hint); + font-size: var(--font-xs); + white-space: nowrap; +} diff --git a/src/renderer/src/composables/useCommands.js b/src/renderer/src/composables/useCommands.js index 096213d..cad793b 100644 --- a/src/renderer/src/composables/useCommands.js +++ b/src/renderer/src/composables/useCommands.js @@ -12,6 +12,7 @@ import { useConfigBackupStore } from '../features/configBackup' import { useImageExportStore } from '../features/imageExport' import { useShareStore } from '../features/share' import { useOnboardingStore } from '../features/onboarding' +import { useMergeStore } from '../features/merge' const bundle = () => ({ diff: useDiffStore(), @@ -22,7 +23,8 @@ const bundle = () => ({ configBackup: useConfigBackupStore(), imageExport: useImageExportStore(), share: useShareStore(), - onboarding: useOnboardingStore() + onboarding: useOnboardingStore(), + merge: useMergeStore() }) export function useCommands() { diff --git a/src/renderer/src/features/merge/components/MergeDialog.vue b/src/renderer/src/features/merge/components/MergeDialog.vue new file mode 100644 index 0000000..419c45f --- /dev/null +++ b/src/renderer/src/features/merge/components/MergeDialog.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/renderer/src/features/merge/components/styles/MergeDialog.css b/src/renderer/src/features/merge/components/styles/MergeDialog.css new file mode 100644 index 0000000..7b681d2 --- /dev/null +++ b/src/renderer/src/features/merge/components/styles/MergeDialog.css @@ -0,0 +1,58 @@ +/* One conflict per card, both sides side by side. Colour is the add/del roles + the diff panes already use, so it reads the same on every theme. */ +.merge-bulk { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-2); +} +.merge-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.merge-conflict { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + overflow: hidden; +} +/* Answered: a keyline down the edge rather than a wash, so the two coloured + sides inside keep their own meaning. */ +.merge-conflict.done { + box-shadow: inset 3px 0 0 var(--dg-add); +} +.merge-head { + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); + /* --text-hint, not --text-dim: this carries words a reader reads, and dim's + 3.0 floor is held against --bg-panel while this sits on --bg-elevated. */ + color: var(--text-hint); + font-size: var(--font-sm); +} +.merge-choices { + display: flex; + gap: var(--space-1); + margin-left: auto; +} +.merge-sides { + display: grid; + grid-template-columns: 1fr 1fr; +} +.merge-side { + margin: 0; + padding: var(--space-2) var(--space-3); + font-family: var(--font-mono); + font-size: var(--font-sm); + white-space: pre-wrap; + overflow-x: auto; + color: var(--text); +} +.merge-side.ours { + background: color-mix(in srgb, var(--dg-del) 14%, transparent); + border-right: 1px solid var(--border); +} +.merge-side.theirs { + background: color-mix(in srgb, var(--dg-add) 14%, transparent); +} diff --git a/src/renderer/src/features/merge/index.js b/src/renderer/src/features/merge/index.js new file mode 100644 index 0000000..02bdd4b --- /dev/null +++ b/src/renderer/src/features/merge/index.js @@ -0,0 +1,2 @@ +export { useMergeStore } from './mergeStore' +export { default as MergeDialog } from './components/MergeDialog.vue' diff --git a/src/renderer/src/features/merge/mergeStore.js b/src/renderer/src/features/merge/mergeStore.js new file mode 100644 index 0000000..89acc37 --- /dev/null +++ b/src/renderer/src/features/merge/mergeStore.js @@ -0,0 +1,70 @@ +import { defineStore } from 'pinia' +import { + composeMerge, + conflictCount, + parseConflicts, + unresolvedCount +} from '../../utils/mergeConflicts' + +/** + * A `git mergetool` run, from the moment main hands over the conflicted text to + * the moment the resolved file goes back. The renderer never learns the path it + * is resolving — main has held that since the launch. + */ +export const useMergeStore = defineStore('merge', { + state: () => ({ + open: false, + parsed: null, + /** @type {Array<'ours'|'theirs'|'both'|'neither'|null>} */ + choices: [], + error: '', + saved: false + }), + getters: { + conflicts: (s) => (s.parsed?.segments ?? []).filter((seg) => seg.type === 'conflict'), + total: (s) => conflictCount(s.parsed), + remaining: (s) => unresolvedCount(s.parsed, s.choices), + resolvedText: (s) => composeMerge(s.parsed, s.choices), + // Nothing to decide is not the same as resolved: a file with no conflicts + // is one this tool has no business writing back. + canSave: (s) => + !s.error && conflictCount(s.parsed) > 0 && unresolvedCount(s.parsed, s.choices) === 0 + }, + actions: { + /** @param {string} content the file as git left it, markers and all */ + begin(content) { + this.parsed = parseConflicts(content) + this.choices = new Array(conflictCount(this.parsed)).fill(null) + this.error = this.parsed ? '' : 'unreadable' + this.saved = false + this.open = true + }, + choose(index, choice) { + if (index < 0 || index >= this.choices.length) return + this.choices[index] = this.choices[index] === choice ? null : choice + }, + takeAll(choice) { + this.choices = this.choices.map(() => choice) + }, + async save() { + const text = this.resolvedText + // composeMerge already refuses a half-resolved file; this is the guard + // that keeps a UI mistake from reaching the write. + if (text === null) return false + const res = await window.api.writeMerged(text) + if (!res?.ok) { + this.error = 'write-failed' + return false + } + this.saved = true + this.open = false + return true + }, + // Declining tells main, or the launcher waits on a decision that is not + // coming and the write stays armed for the life of the process. + async close() { + this.open = false + if (!this.saved) await window.api.cancelMerge() + } + } +}) diff --git a/src/renderer/src/stores/diffStore.js b/src/renderer/src/stores/diffStore.js index 57d8ad2..44acc83 100644 --- a/src/renderer/src/stores/diffStore.js +++ b/src/renderer/src/stores/diffStore.js @@ -1,10 +1,11 @@ import { defineStore } from 'pinia' import { resolveAdapter } from '../adapters' -import { structureAdapter } from '../adapters/structureAdapter' import { csvAdapter } from '../adapters/csvAdapter' -import { diffStructures, structuredKind } from '../utils/structuralDiff' +import { structuredKind } from '../utils/structuralDiff' import { delimitedKind } from '../utils/csv' import { restoredSemanticView, shouldOpenSemantic } from '../utils/viewChrome' +import { lockPairOf } from '../utils/lockfile/lockPair' +import { structurePairDiff } from '../utils/structurePair' import { useVaultStore } from './vaultStore' import { useSnippetStore } from './snippetStore' import { isSecret } from '../utils/secretSnippet' @@ -185,30 +186,29 @@ export const useDiffStore = defineStore('diff', { // else a tree. A KEY — as a word it stayed English in every locale. structureLabelKey() { if (this.canCompareDiagram) return 'appToolbar.structureDiagram' + if (this.canCompareDeps) return 'appToolbar.structureDeps' return this.delimitedFormat ? 'appToolbar.structureGrid' : 'appToolbar.structure' }, structureDiff() { - const kind = this.structuredFormat - if (!kind) return null - const left = structureAdapter.toComparable(this.left, kind) - const right = structureAdapter.toComparable(this.right, kind) - if (left.error || right.error) return null - return diffStructures(left.value, right.value) - }, - // Which viewer the loaded comparison needs: 'text' (Monaco), 'spreadsheet' - // (grid), 'tree' (structure) or 'streamed' (virtualized rows). Text is the - // default so an empty/paste state routes to Monaco. Streamed wins over the - // other side's kind: one file too large to hold makes the whole comparison - // streamed — and a streamed side has no content in memory, so the structure - // toggle above it can never be on. - // Which viewer the semantic toggle asks for, or null when it is off. Split - // out so comparableKind stays one question: semantic, or the file's own kind. + return structurePairDiff(this.left, this.right, this.structuredFormat) + }, + lockPair() { + return lockPairOf(this.left, this.right) + }, + canCompareDeps() { + return this.lockPair !== null + }, + // Deps outranks tree: a lockfile IS json, and a 780-key tree is not what + // the reader opened it for. semanticKind() { if (!this.semanticView) return null if (this.canCompareDiagram) return 'diagram' + if (this.canCompareDeps) return 'deps' if (this.delimitedFormat) return 'spreadsheet' return this.canCompareStructure ? 'tree' : null }, + // Text is the default so an empty/paste state routes to Monaco; streamed + // wins, because a file too large to hold has no content to compare. comparableKind() { const semantic = this.semanticKind if (semantic) return semantic diff --git a/src/renderer/src/utils/commands.js b/src/renderer/src/utils/commands.js index 6a69ba3..15792e2 100644 --- a/src/renderer/src/utils/commands.js +++ b/src/renderer/src/utils/commands.js @@ -142,6 +142,12 @@ export const CLI_COMMANDS = { // Typed in the terminal, so it is saved outright rather than opened in the // editor — the reader has already answered every question the editor asks. 'new-snippet': ({ snippets }, command) => snippets.add(command.draft), + // git hands over the two conflicting versions AND the file it wants written; + // the sides open as an ordinary comparison, the conflicts open over them. + merge: async ({ diff, tabs, merge }, command) => { + await compareFromCli({ diff, tabs }, [command.local, command.remote], true) + merge.begin(command.content) + }, compare: ({ diff, tabs }, command) => compareFromCli({ diff, tabs }, command.files, command.transient === true), // The passphrase is asked for here, not in the terminal: the bundle is @@ -176,6 +182,13 @@ async function compareFromCli({ diff, tabs }, files, transient) { tabs.markActiveTransient(transient) const sides = ['left', 'right'] for (const [i, path] of (files ?? []).entries()) { + // A side main already read — a file out of a git revision — arrives whole, + // because a temp copy would have to live somewhere file:read is allowed to + // reach and there is nowhere it both may and should. + if (path && typeof path === 'object') { + diff.receive(sides[i], path) + continue + } // A path from a shell is not one the app chose, so the read is allowed // to fail outright rather than answer with an error shape. try { diff --git a/src/renderer/src/utils/lockfile/composer.js b/src/renderer/src/utils/lockfile/composer.js new file mode 100644 index 0000000..df56f8f --- /dev/null +++ b/src/renderer/src/utils/lockfile/composer.js @@ -0,0 +1,41 @@ +// composer.lock (PHP). Two arrays rather than a map — `packages` is production +// and `packages-dev` is not — and a licence is a list, because a package may be +// dual-licensed. Pure. + +// "v1.2.3" is as common as "1.2.3" in this ecosystem, and "dev-main" is a branch +// pin that is a real version here. +const version = (entry) => (typeof entry?.version === 'string' ? entry.version : '') + +const license = (entry) => { + if (Array.isArray(entry?.license)) + return entry.license.filter((l) => typeof l === 'string').join(', ') + return typeof entry?.license === 'string' ? entry.license : '' +} + +function collect(list, dev, out) { + if (!Array.isArray(list)) return + for (const entry of list) { + if (typeof entry?.name !== 'string' || !version(entry)) continue + out.push({ + name: entry.name, + version: version(entry), + // composer.lock is the resolved set; which of them composer.json asked for + // is not in this file. + direct: false, + dev, + license: license(entry), + resolved: typeof entry?.dist?.url === 'string' ? entry.dist.url : '' + }) + } +} + +/** @returns {{packages: Array, knowsDirect: boolean}|null} */ +export function parseComposerLock(text) { + const doc = JSON.parse(text) + if (!doc || typeof doc !== 'object') return null + if (!Array.isArray(doc.packages) && !Array.isArray(doc['packages-dev'])) return null + const out = [] + collect(doc.packages, false, out) + collect(doc['packages-dev'], true, out) + return out.length ? { packages: out, knowsDirect: false } : null +} diff --git a/src/renderer/src/utils/lockfile/go.js b/src/renderer/src/utils/lockfile/go.js new file mode 100644 index 0000000..1904624 --- /dev/null +++ b/src/renderer/src/utils/lockfile/go.js @@ -0,0 +1,17 @@ +// go.sum — two lines per module, one for the zip and one for its go.mod. The +// module is what changed; the pair of hashes is not. Pure. + +/** @returns {{packages: Array, knowsDirect: boolean}|null} */ +export function parseGoSum(text) { + const seen = new Map() + for (const raw of String(text).split('\n')) { + const [name, version] = raw.trim().split(/\s+/) + if (!name || !version || !version.startsWith('v')) continue + // `v1.2.3/go.mod` is the same module at the same version. + const bare = version.replace(/\/go\.mod$/, '') + if (!seen.has(name)) { + seen.set(name, { name, version: bare, direct: false, dev: false, license: '', resolved: '' }) + } + } + return seen.size ? { packages: [...seen.values()], knowsDirect: false } : null +} diff --git a/src/renderer/src/utils/lockfile/lockDiff.js b/src/renderer/src/utils/lockfile/lockDiff.js new file mode 100644 index 0000000..da278db --- /dev/null +++ b/src/renderer/src/utils/lockfile/lockDiff.js @@ -0,0 +1,155 @@ +// What changed between two lockfiles, as the dependency moves they describe +// rather than as the thousands of lines they are written in. Pure. + +const RANK = { added: 0, removed: 1, bumped: 2, downgraded: 3 } + +// Leading `v`, a prerelease tag and build metadata all decorate a version +// without changing which release it is. +function triple(version) { + const bare = String(version ?? '') + .trim() + .replace(/^v/i, '') + .split(/[-+]/)[0] + const parts = bare.split('.') + if (parts.length < 2) return null + const nums = parts.slice(0, 3).map((p) => Number(p)) + if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null + return { major: nums[0], minor: nums[1], patch: nums[2] ?? 0 } +} + +/** + * The step between two versions, in the direction of travel — a downgrade of a + * major is still a major move. + * @returns {'major'|'minor'|'patch'|'same'|'unknown'} + */ +export function semverStep(from, to) { + const a = triple(from) + const b = triple(to) + if (!a || !b) return 'unknown' + if (a.major === b.major && a.minor === b.minor && a.patch === b.patch) return 'same' + if (a.major !== b.major) return 'major' + // Below 1.0.0 the minor IS the breaking position, and a lockfile is full of + // packages that never left 0.x. + if (a.minor !== b.minor) return a.major === 0 ? 'major' : 'minor' + return 'patch' +} + +// A name can be installed at more than one version at once, so a side is a set +// of versions per name, not a version per name. +function index(lock) { + const map = new Map() + for (const pkg of lock?.packages ?? []) { + if (!map.has(pkg.name)) map.set(pkg.name, new Map()) + map.get(pkg.name).set(pkg.version, pkg) + } + return map +} + +// Which way the version moved. Compares the triples, not the strings, so +// `1.10.0` does not read as older than `1.9.0`. +function moveOf(from, to) { + const a = triple(from.version) + const b = triple(to.version) + if (!a || !b) return 'bumped' + const back = + b.major < a.major || + (b.major === a.major && b.minor < a.minor) || + (b.major === a.major && b.minor === a.minor && b.patch < a.patch) + return back ? 'downgraded' : 'bumped' +} + +function statusOf(from, to) { + if (from && to) return moveOf(from, to) + return from ? 'removed' : 'added' +} + +// A licence only CHANGED where both sides stated one; a lockfile that never +// records licences must not report every package as relicensed. +const licenseMoved = (from, to) => + Boolean(from?.license) && Boolean(to?.license) && from.license !== to.license + +function rowFor(name, from, to) { + // The surviving side describes the row: what a package IS after the change, + // or what it was when it went. + const now = to ?? from ?? {} + const both = Boolean(from) && Boolean(to) + return { + name, + status: statusOf(from, to), + from: from?.version ?? '', + to: to?.version ?? '', + step: both ? semverStep(from.version, to.version) : 'unknown', + direct: now.direct === true, + dev: now.dev === true, + license: now.license ?? '', + licenseChanged: licenseMoved(from, to) + } +} + +// Ordered the way versions order, not the way strings do: sorting `1.10.0` +// before `1.9.0` paired the wrong two and reported a downgrade that never +// happened. +function byVersion(a, b) { + const x = triple(a) + const y = triple(b) + if (!x || !y) return a < b ? -1 : 1 + return x.major - y.major || x.minor - y.minor || x.patch - y.patch +} + +// The versions of one name that only one side has. What both sides hold did not +// move, whatever else did. +function moved(leftVersions, rightVersions) { + const common = new Set([...leftVersions.keys()].filter((v) => rightVersions.has(v))) + const gone = [...leftVersions.keys()].filter((v) => !common.has(v)).sort(byVersion) + const came = [...rightVersions.keys()].filter((v) => !common.has(v)).sort(byVersion) + return { gone, came } +} + +function rowsForName(name, leftVersions, rightVersions) { + const { gone, came } = moved(leftVersions ?? new Map(), rightVersions ?? new Map()) + const rows = [] + const pairs = Math.min(gone.length, came.length) + for (let i = 0; i < pairs; i++) { + rows.push(rowFor(name, leftVersions.get(gone[i]), rightVersions.get(came[i]))) + } + for (let i = pairs; i < gone.length; i++) rows.push(rowFor(name, leftVersions.get(gone[i]), null)) + for (let i = pairs; i < came.length; i++) + rows.push(rowFor(name, null, rightVersions.get(came[i]))) + return rows +} + +// What the reader asked for comes first — it is the part they can act on. +function ordered(rows) { + return rows.sort((a, b) => { + if (a.direct !== b.direct) return a.direct ? -1 : 1 + if (a.name !== b.name) return a.name < b.name ? -1 : 1 + return RANK[a.status] - RANK[b.status] + }) +} + +/** + * @param {object} left a parsed lockfile (see lockfile/parse) + * @param {object} right + * @returns {{rows: Array, stats: {added:number,removed:number,bumped:number, + * downgraded:number}, directCount:number, knowsDirect:boolean, + * identical:boolean}} + */ +export function diffLocks(left, right) { + const a = index(left) + const b = index(right) + const rows = [] + for (const name of new Set([...a.keys(), ...b.keys()])) { + rows.push(...rowsForName(name, a.get(name), b.get(name))) + } + const stats = { added: 0, removed: 0, bumped: 0, downgraded: 0 } + for (const row of rows) stats[row.status] += 1 + return { + rows: ordered(rows), + stats, + directCount: rows.filter((r) => r.direct).length, + // Only claim the split where BOTH sides recorded it; one side guessing is + // the same as not knowing. + knowsDirect: left?.knowsDirect === true && right?.knowsDirect === true, + identical: rows.length === 0 + } +} diff --git a/src/renderer/src/utils/lockfile/lockPair.js b/src/renderer/src/utils/lockfile/lockPair.js new file mode 100644 index 0000000..d3506b5 --- /dev/null +++ b/src/renderer/src/utils/lockfile/lockPair.js @@ -0,0 +1,13 @@ +// The two sides as a comparable pair of lockfiles, or nothing. Pure. +import { parseLockfile } from './parse' + +/** + * Both sides must parse AND be the same ecosystem: a package-lock against a + * go.sum is two dependency lists, not a comparison. + * @returns {{left: object, right: object}|null} + */ +export function lockPairOf(left, right) { + const a = parseLockfile(left?.content, left?.name) + const b = parseLockfile(right?.content, right?.name) + return a && b && a.kind === b.kind ? { left: a, right: b } : null +} diff --git a/src/renderer/src/utils/lockfile/npm.js b/src/renderer/src/utils/lockfile/npm.js new file mode 100644 index 0000000..c775c74 --- /dev/null +++ b/src/renderer/src/utils/lockfile/npm.js @@ -0,0 +1,85 @@ +// package-lock.json / npm-shrinkwrap.json, both shapes. v2 and v3 carry a flat +// `packages` map keyed by install PATH; v1 carries a nested `dependencies` tree. +// Pure. + +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] + +// "node_modules/a/node_modules/@scope/b" is b, not the path it was hoisted to. +function nameFromPath(path) { + const at = path.lastIndexOf('node_modules/') + return at === -1 ? '' : path.slice(at + 'node_modules/'.length) +} + +function directNames(root) { + const names = new Set() + for (const field of DEP_FIELDS) { + for (const name of Object.keys(root?.[field] ?? {})) names.add(name) + } + return names +} + +// Only the ROOT copy can be the one the manifest asked for. A package installed +// twice — once at the top and once nested under a dependency wanting another +// version — matches by name at both paths. +const packageAt = (path, name, entry, direct) => ({ + name, + version: entry.version, + direct: path === `node_modules/${name}` && direct.has(name), + dev: entry.dev === true, + license: typeof entry.license === 'string' ? entry.license : '', + resolved: typeof entry.resolved === 'string' ? entry.resolved : '' +}) + +function fromPackages(doc) { + const direct = directNames(doc.packages['']) + const out = [] + for (const [path, entry] of Object.entries(doc.packages)) { + // The root itself is the manifest, not a dependency; a link is a workspace + // pointer whose real entry appears under its own path. + if (!path || entry?.link || typeof entry?.version !== 'string') continue + const name = nameFromPath(path) + if (name) out.push(packageAt(path, name, entry, direct)) + } + return out +} + +// v1 nests a dependency's own dependencies inside it, so this walks. Depth is +// bounded because the tree came from JSON.parse, which has already rejected +// anything that did not terminate. +// +// Nothing is marked direct: v1's top level is the HOISTED tree, which holds +// transitive packages too, so depth 0 does not mean "asked for". Reporting it as +// direct would be a guess rendered as a fact. +function walkV1(tree, out) { + for (const [name, entry] of Object.entries(tree ?? {})) { + if (typeof entry?.version !== 'string') continue + out.push({ + name, + version: entry.version, + direct: false, + dev: entry.dev === true, + license: '', + resolved: typeof entry.resolved === 'string' ? entry.resolved : '' + }) + if (entry.dependencies) walkV1(entry.dependencies, out) + } +} + +/** + * @param {string} text + * @returns {{packages: Array, knowsDirect: boolean}|null} null when the text is + * not a lockfile of this shape at all — the caller falls back to the text view. + */ +export function parseNpmLock(text) { + const doc = JSON.parse(text) + if (!doc || typeof doc !== 'object') return null + if (doc.packages && typeof doc.packages === 'object') { + return { packages: fromPackages(doc), knowsDirect: true } + } + if (doc.dependencies && typeof doc.dependencies === 'object') { + const out = [] + walkV1(doc.dependencies, out) + return { packages: out, knowsDirect: false } + } + return null +} diff --git a/src/renderer/src/utils/lockfile/parse.js b/src/renderer/src/utils/lockfile/parse.js new file mode 100644 index 0000000..533845b --- /dev/null +++ b/src/renderer/src/utils/lockfile/parse.js @@ -0,0 +1,63 @@ +// Lockfiles, read as the dependency SETS they describe rather than as the text +// they are written in. One shape out, whatever went in. Pure. +// +// No package manager is consulted and no registry is contacted — everything +// reported comes out of the file in front of the reader (rule 1). +import { parseNpmLock } from './npm' +import { parsePnpmLock } from './pnpm' +import { parseYarnLock } from './yarn' +import { parseGoSum } from './go' +import { parseComposerLock } from './composer' + +/** + * A lockfile is untrusted input. Past this many entries the reader is not + * reading a dependency list, and the renderer should not try to lay one out. + */ +const MAX_PACKAGES = 50000 + +/** Recognised by FILENAME: a lockfile's name is its format. */ +export const LOCKFILE_KINDS = [ + { name: 'package-lock.json', kind: 'npm', parse: parseNpmLock }, + { name: 'npm-shrinkwrap.json', kind: 'npm', parse: parseNpmLock }, + { name: 'pnpm-lock.yaml', kind: 'pnpm', parse: parsePnpmLock }, + { name: 'yarn.lock', kind: 'yarn', parse: parseYarnLock }, + { name: 'go.sum', kind: 'go', parse: parseGoSum }, + { name: 'composer.lock', kind: 'composer', parse: parseComposerLock } +] + +const baseName = (name) => + String(name ?? '') + .split(/[\\/]/) + .pop() + .toLowerCase() + +/** Whether this file is one this module can read, by name alone. */ +export function lockfileKind(fileName) { + return LOCKFILE_KINDS.find((k) => k.name === baseName(fileName)) ?? null +} + +/** + * @param {string} text + * @param {string} fileName the name decides the format + * @returns {{kind: string, packages: Array<{name: string, version: string, + * direct: boolean, dev: boolean, license: string, resolved: string}>, + * knowsDirect: boolean, truncated: boolean}|null} null when the file is not a + * lockfile, or is one that will not parse — the caller keeps the text view. + */ +export function parseLockfile(text, fileName) { + const format = lockfileKind(fileName) + if (!format || typeof text !== 'string') return null + let parsed + try { + parsed = format.parse(text) + } catch { + return null + } + if (!parsed?.packages?.length) return null + return { + kind: format.kind, + packages: parsed.packages.slice(0, MAX_PACKAGES), + knowsDirect: parsed.knowsDirect === true, + truncated: parsed.packages.length > MAX_PACKAGES + } +} diff --git a/src/renderer/src/utils/lockfile/pnpm.js b/src/renderer/src/utils/lockfile/pnpm.js new file mode 100644 index 0000000..70dd6f0 --- /dev/null +++ b/src/renderer/src/utils/lockfile/pnpm.js @@ -0,0 +1,58 @@ +// pnpm-lock.yaml. Packages are keyed `name@version`, and the `importers` block +// is the only place that says which of them the project actually asked for. +// Pure. +import { parse as parseYaml } from 'yaml' + +// Same guard structuralDiff uses: an anchor bomb must not expand. +const YAML_OPTIONS = { maxAliasCount: 100, prettyErrors: false } + +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] + +/** + * `@vue/shared@3.4.21` -> the LAST `@` is the separator, so a scoped name keeps + * its own. A peer suffix — `vue@3.4.21(typescript@5.4.0)` — names a variant of + * the same package, not a different one. + */ +export function splitPackageKey(key) { + const bare = String(key) + .replace(/^\//, '') + .replace(/\(.*\)$/, '') + const at = bare.lastIndexOf('@') + if (at <= 0) return null + const name = bare.slice(0, at) + const version = bare.slice(at + 1) + return name && version ? { name, version } : null +} + +function importerNames(importers) { + const direct = new Map() + for (const importer of Object.values(importers ?? {})) { + for (const field of DEP_FIELDS) { + for (const name of Object.keys(importer?.[field] ?? {})) { + direct.set(name, field === 'devDependencies' || direct.get(name) === true) + } + } + } + return direct +} + +/** @returns {{packages: Array, knowsDirect: boolean}|null} */ +export function parsePnpmLock(text) { + const doc = parseYaml(text, YAML_OPTIONS) + if (!doc || typeof doc !== 'object' || !doc.packages) return null + const direct = importerNames(doc.importers) + const out = [] + for (const key of Object.keys(doc.packages)) { + const split = splitPackageKey(key) + if (!split) continue + out.push({ + name: split.name, + version: split.version, + direct: direct.has(split.name), + dev: direct.get(split.name) === true, + license: '', + resolved: '' + }) + } + return { packages: out, knowsDirect: true } +} diff --git a/src/renderer/src/utils/lockfile/yarn.js b/src/renderer/src/utils/lockfile/yarn.js new file mode 100644 index 0000000..438f11b --- /dev/null +++ b/src/renderer/src/utils/lockfile/yarn.js @@ -0,0 +1,38 @@ +// yarn.lock v1 — a bespoke text format, not YAML. Blocks are separated by blank +// lines; a block's header is one or more comma-separated `name@range` specs +// ending in a colon, and its `version "x"` line is the resolved version. Pure. + +// `"@vue/shared@3.4.21"` and `lodash@^4.0.0` — the LAST `@` separates the range, +// so a scoped name keeps its own. +function nameFromSpec(spec) { + const bare = spec.trim().replace(/^"|"$/g, '') + const at = bare.lastIndexOf('@') + return at > 0 ? bare.slice(0, at) : bare +} + +const isHeader = (line) => !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':') + +/** + * @returns {{packages: Array, knowsDirect: boolean}|null} `knowsDirect` is false: + * a v1 lockfile records ranges, never which of them the manifest asked for. + */ +export function parseYarnLock(text) { + const lines = String(text).split('\n') + const out = [] + let name = '' + for (const raw of lines) { + const line = raw.trimEnd() + if (isHeader(line)) { + // Every spec in the header resolves to the same package, so the first + // names it. + name = nameFromSpec(line.slice(0, -1).split(',')[0]) + continue + } + const version = /^\s+version\s+"?([^"\s]+)"?/.exec(line) + if (name && version) { + out.push({ name, version: version[1], direct: false, dev: false, license: '', resolved: '' }) + name = '' + } + } + return out.length ? { packages: out, knowsDirect: false } : null +} diff --git a/src/renderer/src/utils/mergeConflicts.js b/src/renderer/src/utils/mergeConflicts.js new file mode 100644 index 0000000..15d1c6f --- /dev/null +++ b/src/renderer/src/utils/mergeConflicts.js @@ -0,0 +1,118 @@ +// A file as git left it mid-merge: stable text with conflict regions between, +// each carrying both sides and — in diff3 style — the ancestor they came from. +// Pure, and it composes the resolved file back rather than editing in place. +// +// Every line keeps the ending it arrived with. git writes its markers with LF +// even into a CRLF file, so a single file-wide line ending found no conflicts in +// a genuinely mixed file — and writing that back put the markers on disk. + +// Exactly seven characters, then end-of-line or a space before the label. A +// line merely STARTING with them is ordinary text. +const OURS = /^<<<<<<<(?: (.*))?$/ +const BASE = /^\|\|\|\|\|\|\|(?: (.*))?$/ +const SPLIT = /^=======$/ +const THEIRS = /^>>>>>>>(?: (.*))?$/ + +const textOf = (raw) => raw.replace(/\r?\n$/, '') +const label = (match) => (match?.[1] ?? '').trim() + +const stable = (raw) => ({ type: 'stable', raw, lines: raw.map(textOf) }) + +// A conflict git wrote always closes. One that does not is a file someone was +// editing by hand, and guessing which side the rest belongs to would silently +// drop the other. +function conflictAt(raw, start) { + const sides = { ours: [], base: null, theirs: [] } + let side = 'ours' + for (let i = start + 1; i < raw.length; i++) { + const line = textOf(raw[i]) + const closing = THEIRS.exec(line) + if (closing) { + return { + conflict: { + type: 'conflict', + oursLabel: label(OURS.exec(textOf(raw[start]))), + theirsLabel: label(closing), + ours: sides.ours.map(textOf), + theirs: sides.theirs.map(textOf), + base: sides.base ? sides.base.map(textOf) : null, + oursRaw: sides.ours, + theirsRaw: sides.theirs + }, + end: i + } + } + if (BASE.test(line)) { + sides.base = [] + side = 'base' + } else if (SPLIT.test(line)) side = 'theirs' + else sides[side].push(raw[i]) + } + return null +} + +/** + * @param {string} text + * @returns {{segments: Array}|null} null when the file's markers do not close. + */ +export function parseConflicts(text) { + // Split AFTER each newline, so a line carries its own ending and a mixed file + // survives the round trip byte for byte. + const raw = String(text ?? '').split(/(?<=\n)/) + const segments = [] + let held = [] + for (let i = 0; i < raw.length; i++) { + if (!OURS.test(textOf(raw[i]))) { + held.push(raw[i]) + continue + } + const found = conflictAt(raw, i) + if (!found) return null + // An empty run between two conflicts, or before the first, is not a segment + // anyone renders — a file that opens on a conflict has nothing above it. + if (held.length) segments.push(stable(held)) + segments.push(found.conflict) + held = [] + i = found.end + } + if (held.length) segments.push(stable(held)) + return { segments } +} + +const conflicts = (parsed) => (parsed?.segments ?? []).filter((s) => s.type === 'conflict') + +/** How many regions the reader has to decide. */ +export function conflictCount(parsed) { + return conflicts(parsed).length +} + +/** How many of them are still undecided. */ +export function unresolvedCount(parsed, choices = []) { + return conflicts(parsed).filter((_, i) => !CHOICES[choices[i]]).length +} + +// Both keeps the file's own order — ours came first in it. +const CHOICES = { + ours: (c) => c.oursRaw, + theirs: (c) => c.theirsRaw, + both: (c) => [...c.oursRaw, ...c.theirsRaw], + neither: () => [] +} + +/** + * The resolved file, or null while any conflict is undecided — writing a + * half-resolved file would hand git one with markers still in it. + * @param {object} parsed from parseConflicts + * @param {Array<'ours'|'theirs'|'both'|'neither'|null>} choices one per conflict + * @returns {string|null} + */ +export function composeMerge(parsed, choices = []) { + if (!parsed || unresolvedCount(parsed, choices)) return null + const out = [] + let at = 0 + for (const segment of parsed.segments) { + if (segment.type === 'stable') out.push(...segment.raw) + else out.push(...CHOICES[choices[at++]](segment)) + } + return out.join('') +} diff --git a/src/renderer/src/utils/structurePair.js b/src/renderer/src/utils/structurePair.js new file mode 100644 index 0000000..6a490e8 --- /dev/null +++ b/src/renderer/src/utils/structurePair.js @@ -0,0 +1,18 @@ +// The structural comparison of two loaded files, or null where there is nothing +// to compare. Lifted out of diffStore so the store holds the question and this +// holds the work. Pure. +import { structureAdapter } from '../adapters/structureAdapter' +import { diffStructures } from './structuralDiff' + +/** + * @param {object} left a loaded file + * @param {object} right + * @param {string|null} kind the structured format both sides are, if any + */ +export function structurePairDiff(left, right, kind) { + if (!kind) return null + const a = structureAdapter.toComparable(left, kind) + const b = structureAdapter.toComparable(right, kind) + if (a.error || b.error) return null + return diffStructures(a.value, b.value) +} diff --git a/src/renderer/src/utils/viewChrome.js b/src/renderer/src/utils/viewChrome.js index 01cbab1..3af83e3 100644 --- a/src/renderer/src/utils/viewChrome.js +++ b/src/renderer/src/utils/viewChrome.js @@ -49,7 +49,8 @@ export const showsWhitespaceToggle = (store) => * @param {object} store * @returns {boolean} */ -export const shouldOpenSemantic = (store) => !!store?.canCompareDiagram || !!store?.delimitedFormat +export const shouldOpenSemantic = (store) => + !!store?.canCompareDiagram || !!store?.canCompareDeps || !!store?.delimitedFormat /** * The view a restored snapshot opens in: the one it recorded, or — where it diff --git a/src/shared/cliCommands.js b/src/shared/cliCommands.js index bffcdff..8f95a69 100644 --- a/src/shared/cliCommands.js +++ b/src/shared/cliCommands.js @@ -20,6 +20,12 @@ export const COMMANDS = [ summaryKey: 'cli.difftool.summary', detailKey: 'cli.difftool.detail' }, + { + topic: 'mergetool', + usage: 'diffbro mergetool ', + summaryKey: 'cli.mergetool.summary', + detailKey: 'cli.mergetool.detail' + }, { topic: 'open', usage: 'diffbro open []', diff --git a/src/shared/i18n/en-XA.json b/src/shared/i18n/en-XA.json index 9331d4a..93fefe8 100644 --- a/src/shared/i18n/en-XA.json +++ b/src/shared/i18n/en-XA.json @@ -16,6 +16,18 @@ "hint": "[Ṁéńūş àńđ àƥƥ ţéẋţ. Řéşţàřţ ĩş ńōţ ńééđéđ. ·øé·øé·øé·øé·ø]" } }, + "merge": { + "title": "[Řéşōłṽé ɱéřğé çōńƒłĩçţş ·øé·øé·øé]", + "remaining": "[Ńōţĥĩńğ łéƒţ ţō đéçĩđé | {n} çōńƒłĩçţ şţĩłł ţō đéçĩđé | {n} çōńƒłĩçţş şţĩłł ţō đéçĩđé ·øé·øé·øé·øé·øé·øé·øé·øé·ø]", + "conflictN": "[Çōńƒłĩçţ {n} ·øé·]", + "takeOurs": "[Ōūřş ·ø]", + "takeTheirs": "[Ţĥéĩřş ·øé]", + "takeBoth": "[Ɓōţĥ ·ø]", + "takeNeither": "[Ńéĩţĥéř ·øé]", + "allOf": "[{side} éṽéřŷŵĥéřé ·øé·øé]", + "save": "[Šàṽé ţĥé ɱéřğé ·øé·ø]", + "unreadable": "[Ţĥàţ ƒĩłé’ş çōńƒłĩçţ ɱàřķéřş đō ńōţ çłōşé, şō ţĥéřé ĩş ńōţĥĩńğ şàƒé ţō řéşōłṽé. Ƒĩńĩşĥ ĩţ ƀŷ ĥàńđ. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·]" + }, "menu": { "file": { "title": "[Ƒĩłé ·ø]", @@ -479,6 +491,7 @@ "structure": "[Šţřūçţūřé ·øé·]", "structureGrid": "[Ğřĩđ ·ø]", "structureDiagram": "[Đĩàğřàɱ ·øé]", + "structureDeps": "[Đéƥéńđéńçĩéş ·øé·ø]", "tips": { "view": "[Šƥłĩţ ṽĩéŵ, şţřūçţūřé àńđ ŵĥĩţéşƥàçé — éṽéřŷţĥĩńğ ţĥàţ çĥàńğéş ĥōŵ ţĥé çōɱƥàřĩşōń ĩş đřàŵń ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé]", "more": "[Àçţĩōńş ţĥé ƀàř ĥàş ńō řōōɱ ƒōř ·øé·øé·øé·]", @@ -526,6 +539,15 @@ "noFreeTab": "[Ńō ƒřéé ţàƀ ·øé·]", "gotIt": "[Ğōţ ĩţ ·ø]" }, + "cliErrors": { + "not-a-repo": "[Ţĥàţ ƒōłđéř ĩş ńōţ ĩńşĩđé à ğĩţ řéƥōşĩţōřŷ. ·øé·øé·øé·øé·ø]", + "no-such-revision": "[ğĩţ đōéş ńōţ ķńōŵ ţĥàţ řéṽĩşĩōń. ·øé·øé·øé·ø]", + "not-in-revision": "[Ţĥàţ ƒĩłé ĩş ńōţ ĩń ţĥàţ řéṽĩşĩōń. ·øé·øé·øé·ø]", + "merge-unreadable": "[Ţĥàţ ƒĩłé çōūłđ ńōţ ƀé řéàđ. ·øé·øé·øé]", + "merge-binary": "[Ţĥàţ ĩş à ƀĩńàřŷ ƒĩłé — řéşōłṽé ĩţ ŵĩţĥ `ğĩţ çĥéçķōūţ --ōūřş/--ţĥéĩřş`. ·øé·øé·øé·øé·øé·øé·øé]", + "merge-no-conflicts": "[Ţĥàţ ƒĩłé ĥàş ńō çōńƒłĩçţ ɱàřķéřş, şō ţĥéřé ĩş ńōţĥĩńğ ţō řéşōłṽé. ·øé·øé·øé·øé·øé·øé·øé·]", + "refused": "[Ţĥàţ řéṽĩşĩōń ōř ƥàţĥ ŵàş řéƒūşéđ. ·øé·øé·øé·øé]" + }, "cliSettings": { "terminalCommand": "[Ţéřɱĩńàł çōɱɱàńđ ·øé·øé]", "intro": "[Àđđş à {cmd} çōɱɱàńđ şō à çōɱƥàřĩşōń çàń şţàřţ ƒřōɱ à ţéřɱĩńàł: {compare}, {create}, ōř {save} ţō ķééƥ ŵĥàţ ŷōū ĵūşţ çōƥĩéđ. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·]", @@ -557,6 +579,23 @@ "showTheChangeList": "[Šĥōŵ ţĥé çĥàńğé łĩşţ ·øé·øé·]", "notComparableAsPicture": "[Ţĥĩş đĩàğřàɱ ţŷƥé çàń’ţ ƀé çōɱƥàřéđ àş à ƥĩçţūřé ŷéţ. ·øé·øé·øé·øé·øé·ø]" }, + "depsDiffViewer": { + "packages": "[Ƥàçķàğéş ·øé·]", + "changed": "[Çĥàńğéđ ·øé]", + "askedFor": "[Àşķéđ ƒōř ·øé·]", + "direct": "[àşķéđ ƒōř ·øé·]", + "dev": "[đéṽ ·ø]", + "noDependencyChanged": "[Ńō đéƥéńđéńçŷ çĥàńğéđ — ţĥé şàɱé ṽéřşĩōńş, éĩţĥéř şĩđé ·øé·øé·øé·øé·øé·øé]", + "showCarried": "[Šĥōŵ {n} çàřřĩéđ ƥàçķàğé | Šĥōŵ {n} çàřřĩéđ ƥàçķàğéş ·øé·øé·øé·øé·øé·]", + "askedForOnly": "[Ōńłŷ ŵĥàţ ŷōū àşķéđ ƒōř ·øé·øé·ø]", + "countBumped": "[{n} ƀūɱƥéđ ·øé]", + "countDowngraded": "[{n} đōŵńğřàđéđ ·øé·ø]", + "step": { + "major": "[ɱàĵōř ·ø]", + "minor": "[ɱĩńōř ·ø]", + "patch": "[ƥàţçĥ ·ø]" + } + }, "diffTabBar": { "previousComparison": "[Ƥřéṽĩōūş çōɱƥàřĩşōń ·øé·øé·ø]", "openComparisons": "[Ōƥéń çōɱƥàřĩşōńş ·øé·øé]", @@ -623,7 +662,7 @@ "gitToolSettings": { "git": "[Ğĩţ ·ø]", "openGitComparisonsInDiff": "[Ōƥéń ğĩţ çōɱƥàřĩşōńş ĩń Đĩƒƒ Ɓřō ·øé·øé·øé·ø]", - "aMergeOpensItsTwo": "[À ɱéřğé ōƥéńş ĩţş ţŵō çōńƒłĩçţĩńğ ṽéřşĩōńş şĩđé ƀŷ şĩđé ţō řéàđ. Đĩƒƒ Ɓřō đōéşń’ţ ŵřĩţé ţĥé ɱéřğéđ ƒĩłé, şō ğĩţ şţĩłł àşķş ŷōū ŵĥéţĥéř ţĥé ɱéřğé ŵōřķéđ. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·]", + "aMergeOpensItsTwo": "[À ɱéřğé ōƥéńş ĩţş ţŵō çōńƒłĩçţĩńğ ṽéřşĩōńş, ţàķéş à çĥōĩçé ƥéř çōńƒłĩçţ, àńđ ŵřĩţéş ţĥé ɱéřğéđ ƒĩłé ƀàçķ — şō ğĩţ ĩş ţōłđ ţĥé ɱéřğé ĩş đōńé řàţĥéř ţĥàń àşķĩńğ ŷōū. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·]", "intro": "[Ṁàķéş {difftool} àńđ {mergetool} ōƥéń ţĥé çōɱƥàřĩşōń ĥéřé ĩńşţéàđ ōƒ ĩń ţĥé ţéřɱĩńàł. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·]", "notOnPath": "[ğĩţ ĩşń’ţ ōń ŷōūř ƤÀŢĤ, şō ţĥéřé ĩş ńōţĥĩńğ ţō řéğĩşţéř. ·øé·øé·øé·øé·øé·øé]", "configFailed": "[Çōūłđ ńōţ çĥàńğé ţĥé ğĩţ çōńƒĩğūřàţĩōń. ·øé·øé·øé·øé·ø]" @@ -1506,7 +1545,8 @@ "moved": "[{n} ɱōṽéđ ·øé]", "sheets": "[{n} şĥééţ | {n} şĥééţş ·øé·øé]", "matches": "[{n} ɱàţçĥ | {n} ɱàţçĥéş ·øé·øé]", - "unchangedHidden": "[{n} ūńçĥàńğéđ ĥĩđđéń ·øé·øé·]" + "unchangedHidden": "[{n} ūńçĥàńğéđ ĥĩđđéń ·øé·øé·]", + "packages": "[{n} ƥàçķàğé | {n} ƥàçķàğéş ·øé·øé·]" }, "sheetTabBar": { "onlyIn": "[ōńłŷ {side} ·øé·]", @@ -1578,6 +1618,10 @@ "summary": "[ōƥéń à çōɱƥàřĩşōń ğĩţ ĥàńđéđ ōṽéř ·øé·øé·øé·øé]", "detail": "[Ŵĥàţ `ğĩţ đĩƒƒţōōł` àńđ `ğĩţ ɱéřğéţōōł` řūń. Šàɱé àş çōɱƥàřé,\néẋçéƥţ ţĥé ţŵō ƒĩłéş àřé ķńōŵń ţō ƀé ţĥřōŵàŵàŷ çōƥĩéş ğĩţ ɱàđé.\n\nƁéçàūşé ţĥéŷ àřé ţĥřōŵàŵàŷ, à ɱéřğé ŵĩţĥ ɱōřé çōńƒłĩçţş ţĥàń ţĥéřé àřé\nţàƀş řéūşéş ţĥé ōłđéşţ ōƒ ţĥéɱ ĩńşţéàđ ōƒ řūńńĩńğ ōūţ — `ğĩţ ɱéřğéţōōł`\nŵàłķş ţĥé ŵĥōłé çōńƒłĩçţ łĩşţ ŵĩţĥōūţ ŵàĩţĩńğ ƒōř àńŷōńé. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·ø]" }, + "mergetool": { + "summary": "[Řéşōłṽé à ɱéřğé çōńƒłĩçţ ğĩţ ĥàńđéđ ōṽéř ·øé·øé·øé·øé·ø]", + "detail": "[Çàłłéđ ƀŷ `ğĩţ ɱéřğéţōōł`, ńōţ ūşūàłłŷ ƀŷ ĥàńđ. Ōƥéńş ţĥé ţŵō çōńƒłĩçţĩńğ ṽéřşĩōńş, ţàķéş à çĥōĩçé ƥéř çōńƒłĩçţ, àńđ ŵřĩţéş ţĥé ɱéřğéđ ƒĩłé ƀàçķ. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·]" + }, "open": { "summary": "[řàĩşé ţĥé àƥƥ, ōƥţĩōńàłłŷ ōń à ƒĩłé ·øé·øé·øé·øé]", "detail": "[Ɓřĩńğş Đĩƒƒ Ɓřō ţō ţĥé ƒřōńţ, şţàřţĩńğ ĩţ ĩƒ ĩţ ĩş ńōţ řūńńĩńğ.\n\nŴĩţĥ à ƒĩłé, ţĥàţ ƒĩłé ƒĩłłş ţĥé łéƒţ şĩđé àńđ ŵàĩţş ƒōř ţĥé řĩğĥţ — ţĥé\nşàɱé àş `çōɱƥàřé` ŵĩţĥ ōńé ƥàţĥ, ūńđéř à ńàɱé ţĥàţ řéàđş łĩķé ōƥéńĩńğ.\nƑōř ţŵō ƒĩłéş àţ ōńçé, ūşé `çōɱƥàřé`. ·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·øé·ø]" diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json index 2f28f6a..085f9e7 100644 --- a/src/shared/i18n/en.json +++ b/src/shared/i18n/en.json @@ -16,6 +16,18 @@ "hint": "Menus and app text. Restart is not needed." } }, + "merge": { + "title": "Resolve merge conflicts", + "remaining": "Nothing left to decide | {n} conflict still to decide | {n} conflicts still to decide", + "conflictN": "Conflict {n}", + "takeOurs": "Ours", + "takeTheirs": "Theirs", + "takeBoth": "Both", + "takeNeither": "Neither", + "allOf": "{side} everywhere", + "save": "Save the merge", + "unreadable": "That file’s conflict markers do not close, so there is nothing safe to resolve. Finish it by hand." + }, "menu": { "file": { "title": "File", @@ -479,6 +491,7 @@ "structure": "Structure", "structureGrid": "Grid", "structureDiagram": "Diagram", + "structureDeps": "Dependencies", "tips": { "view": "Split view, structure and whitespace — everything that changes how the comparison is drawn", "more": "Actions the bar has no room for", @@ -526,6 +539,15 @@ "noFreeTab": "No free tab", "gotIt": "Got it" }, + "cliErrors": { + "not-a-repo": "That folder is not inside a git repository.", + "no-such-revision": "git does not know that revision.", + "not-in-revision": "That file is not in that revision.", + "merge-unreadable": "That file could not be read.", + "merge-binary": "That is a binary file — resolve it with `git checkout --ours/--theirs`.", + "merge-no-conflicts": "That file has no conflict markers, so there is nothing to resolve.", + "refused": "That revision or path was refused." + }, "cliSettings": { "terminalCommand": "Terminal command", "intro": "Adds a {cmd} command so a comparison can start from a terminal: {compare}, {create}, or {save} to keep what you just copied.", @@ -557,6 +579,23 @@ "showTheChangeList": "Show the change list", "notComparableAsPicture": "This diagram type can’t be compared as a picture yet." }, + "depsDiffViewer": { + "packages": "Packages", + "changed": "Changed", + "askedFor": "Asked for", + "direct": "asked for", + "dev": "dev", + "noDependencyChanged": "No dependency changed — the same versions, either side", + "showCarried": "Show {n} carried package | Show {n} carried packages", + "askedForOnly": "Only what you asked for", + "countBumped": "{n} bumped", + "countDowngraded": "{n} downgraded", + "step": { + "major": "major", + "minor": "minor", + "patch": "patch" + } + }, "diffTabBar": { "previousComparison": "Previous comparison", "openComparisons": "Open comparisons", @@ -623,7 +662,7 @@ "gitToolSettings": { "git": "Git", "openGitComparisonsInDiff": "Open git comparisons in Diff Bro", - "aMergeOpensItsTwo": "A merge opens its two conflicting versions side by side to read. Diff Bro doesn’t write the merged file, so git still asks you whether the merge worked.", + "aMergeOpensItsTwo": "A merge opens its two conflicting versions, takes a choice per conflict, and writes the merged file back — so git is told the merge is done rather than asking you.", "intro": "Makes {difftool} and {mergetool} open the comparison here instead of in the terminal.", "notOnPath": "git isn’t on your PATH, so there is nothing to register.", "configFailed": "Could not change the git configuration." @@ -1506,7 +1545,8 @@ "moved": "{n} moved", "sheets": "{n} sheet | {n} sheets", "matches": "{n} match | {n} matches", - "unchangedHidden": "{n} unchanged hidden" + "unchangedHidden": "{n} unchanged hidden", + "packages": "{n} package | {n} packages" }, "sheetTabBar": { "onlyIn": "only {side}", @@ -1578,6 +1618,10 @@ "summary": "open a comparison git handed over", "detail": "What `git difftool` and `git mergetool` run. Same as compare,\nexcept the two files are known to be throwaway copies git made.\n\nBecause they are throwaway, a merge with more conflicts than there are\ntabs reuses the oldest of them instead of running out — `git mergetool`\nwalks the whole conflict list without waiting for anyone." }, + "mergetool": { + "summary": "Resolve a merge conflict git handed over", + "detail": "Called by `git mergetool`, not usually by hand. Opens the two conflicting versions, takes a choice per conflict, and writes the merged file back." + }, "open": { "summary": "raise the app, optionally on a file", "detail": "Brings Diff Bro to the front, starting it if it is not running.\n\nWith a file, that file fills the left side and waits for the right — the\nsame as `compare` with one path, under a name that reads like opening.\nFor two files at once, use `compare`." diff --git a/tests/main/cli.test.js b/tests/main/cli.test.js index 089b15a..6d58c9a 100644 --- a/tests/main/cli.test.js +++ b/tests/main/cli.test.js @@ -47,6 +47,37 @@ describe('cliWords', () => { }) }) +describe('parseCli — compare from a revision', () => { + const parse = (...words) => parseCli([...PACKAGED, ...words], (p) => `/cwd/${p}`).command + + // `diffbro compare HEAD~1:src/a.js src/a.js` — the shape git itself uses, so + // the reader does not have to produce a copy of the old file first. + it('keeps a revision argument unresolved, as the pair it names', () => { + expect(parse('compare', 'HEAD~1:src/a.js', 'src/a.js')).toEqual({ + name: 'compare', + files: [{ revision: 'HEAD~1', relPath: 'src/a.js' }, '/cwd/src/a.js'], + transient: false + }) + }) + + it('takes a revision on either side, or both', () => { + expect(parse('compare', 'v1.0.0:a.js', 'HEAD:a.js').files).toEqual([ + { revision: 'v1.0.0', relPath: 'a.js' }, + { revision: 'HEAD', relPath: 'a.js' } + ]) + }) + + // A path is still a path: the feature must not capture every argument that + // happens to contain a colon. + it('leaves an ordinary path alone', () => { + expect(parse('compare', 'a.json').files).toEqual(['/cwd/a.json']) + }) + + it('leaves a Windows absolute path alone', () => { + expect(parse('compare', 'C:/Users/x/a.js').files).toEqual(['/cwd/C:/Users/x/a.js']) + }) +}) + describe('parseCli — compare', () => { it('takes one file', () => { const { command } = parseCli([...PACKAGED, 'compare', 'a.json']) diff --git a/tests/main/gitRepo.test.js b/tests/main/gitRepo.test.js new file mode 100644 index 0000000..3ea4a7c --- /dev/null +++ b/tests/main/gitRepo.test.js @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { + gitEnv, + HARDENING, + isSafeRevision, + readBlobArgs, + repoRootArgs, + resolveRevisionArgs +} from '../../src/main/gitRepo' + +describe('isSafeRevision', () => { + it('takes the revision shapes a reader actually types', () => { + for (const rev of [ + 'HEAD', + 'HEAD~3', + 'HEAD^', + 'main', + 'origin/main', + 'feature/a-b_c', + 'v1.2.3', + '9a73b33', + '9a73b33cd1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8', + 'HEAD@{1}', + 'release-2024.10' + ]) { + expect(isSafeRevision(rev), rev).toBe(true) + } + }) + + // A revision reaches argv. Anything that could be read as an OPTION is the + // one shape that must never get through, however harmless it looks. + it('refuses anything that could be read as an option', () => { + for (const rev of ['--upload-pack=evil', '-c', '--exec=sh', '-']) { + expect(isSafeRevision(rev), rev).toBe(false) + } + }) + + it('refuses shell metacharacters and whitespace', () => { + for (const rev of ['HEAD; rm -rf /', 'HEAD|cat', 'HEAD$(id)', 'HEAD `id`', 'a b', 'HEAD\n']) { + expect(isSafeRevision(rev), rev).toBe(false) + } + }) + + it('refuses nothing, a non-string, and an absurd length', () => { + expect(isSafeRevision('')).toBe(false) + expect(isSafeRevision(null)).toBe(false) + expect(isSafeRevision(42)).toBe(false) + expect(isSafeRevision('a'.repeat(256))).toBe(false) + }) +}) + +describe('the hardening every invocation carries', () => { + // A repository is untrusted input: its own config has been an execution vector + // before. These are the flags that stop a clone running anything. + it('disables hooks, the fsmonitor and any pager', () => { + expect(HARDENING).toContain('-c') + expect(HARDENING.join(' ')).toContain('core.hooksPath=') + expect(HARDENING.join(' ')).toContain('core.fsmonitor=') + expect(HARDENING).toContain('--no-pager') + }) +}) + +describe('argument vectors', () => { + it('asks for the repo root, hardened', () => { + const args = repoRootArgs() + expect(args.slice(0, HARDENING.length)).toEqual(HARDENING) + expect(args).toContain('rev-parse') + expect(args).toContain('--show-toplevel') + }) + + it('resolves a revision to a commit, hardened', () => { + const args = resolveRevisionArgs('HEAD~2') + expect(args.slice(0, HARDENING.length)).toEqual(HARDENING) + expect(args).toEqual([ + ...HARDENING, + 'rev-parse', + '--verify', + '--end-of-options', + 'HEAD~2^{commit}' + ]) + }) + + // `--` is what stops a path called `-x` becoming a flag, and `git show` takes + // the pair as ONE argument, so the path cannot be split off either. + it('reads a blob with the revision and path fenced apart', () => { + const args = readBlobArgs('HEAD', 'src/main/index.js') + expect(args).toEqual([...HARDENING, 'show', '--end-of-options', 'HEAD:src/main/index.js']) + }) + + it('normalises a Windows path separator, because git only speaks posix', () => { + expect(readBlobArgs('HEAD', 'src\\main\\index.js')).toContain('HEAD:src/main/index.js') + }) + + it('refuses to build anything from a revision it would not accept', () => { + expect(() => resolveRevisionArgs('--upload-pack=evil')).toThrow() + expect(() => readBlobArgs('-c', 'a.js')).toThrow() + }) + + it('refuses a path that climbs out of the repository', () => { + expect(() => readBlobArgs('HEAD', '../../etc/passwd')).toThrow() + expect(() => readBlobArgs('HEAD', '/etc/passwd')).toThrow() + }) +}) + +// GIT_DIR, GIT_INDEX_FILE, GIT_ALTERNATE_OBJECT_DIRECTORIES and friends all +// redirect git at something other than the repository main chose. Inheriting the +// launching shell's environment wholesale hands that redirect to whoever set it. +describe('the environment a git invocation inherits', () => { + it('drops every GIT_ variable it was launched with', () => { + const env = gitEnv({ PATH: '/usr/bin', GIT_DIR: '/tmp/evil.git', GIT_INDEX_FILE: '/tmp/i' }) + expect(env.GIT_DIR).toBeUndefined() + expect(env.GIT_INDEX_FILE).toBeUndefined() + }) + + it('keeps what git needs to run at all', () => { + const env = gitEnv({ PATH: '/usr/bin', HOME: '/home/x', SystemRoot: 'C:\\Windows' }) + expect(env.PATH).toBe('/usr/bin') + expect(env.HOME).toBe('/home/x') + expect(env.SystemRoot).toBe('C:\\Windows') + }) + + it('sets the two that make it non-interactive and system-config free', () => { + const env = gitEnv({}) + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1') + expect(env.GIT_TERMINAL_PROMPT).toBe('0') + }) +}) diff --git a/tests/main/gitRevisionArg.test.js b/tests/main/gitRevisionArg.test.js new file mode 100644 index 0000000..c219113 --- /dev/null +++ b/tests/main/gitRevisionArg.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { splitRevisionArg } from '../../src/main/gitRevisionArg' + +describe('splitRevisionArg', () => { + // `diffbro compare HEAD~1:src/a.js` — the shape git itself uses. + it('splits a revision:path argument', () => { + expect(splitRevisionArg('HEAD~1:src/a.js')).toEqual({ + revision: 'HEAD~1', + relPath: 'src/a.js' + }) + }) + + it('takes a branch, a tag and a sha', () => { + expect(splitRevisionArg('main:a.js').revision).toBe('main') + expect(splitRevisionArg('v1.2.3:a.js').revision).toBe('v1.2.3') + expect(splitRevisionArg('9a73b33:a.js').revision).toBe('9a73b33') + }) + + it('keeps a path that itself contains a colon', () => { + expect(splitRevisionArg('HEAD:src/a:b.js')).toEqual({ revision: 'HEAD', relPath: 'src/a:b.js' }) + }) + + // A plain path is a plain path — the feature must not capture every argument + // with a colon in it. + it('is not a revision argument without a colon', () => { + expect(splitRevisionArg('src/a.js')).toBeNull() + expect(splitRevisionArg('')).toBeNull() + expect(splitRevisionArg(null)).toBeNull() + }) + + // A Windows drive letter is a colon that is emphatically not a revision. + it('is not fooled by a Windows absolute path', () => { + expect(splitRevisionArg('C:\\Users\\x\\a.js')).toBeNull() + expect(splitRevisionArg('C:/Users/x/a.js')).toBeNull() + }) + + it('refuses a revision the fence would not accept', () => { + expect(splitRevisionArg('--upload-pack=evil:a.js')).toBeNull() + expect(splitRevisionArg('HEAD;id:a.js')).toBeNull() + }) + + it('refuses an empty half', () => { + expect(splitRevisionArg(':a.js')).toBeNull() + expect(splitRevisionArg('HEAD:')).toBeNull() + }) +}) diff --git a/tests/main/gitTool.test.js b/tests/main/gitTool.test.js index 9807090..09558c5 100644 --- a/tests/main/gitTool.test.js +++ b/tests/main/gitTool.test.js @@ -24,6 +24,7 @@ import { unregisterArgs, unregisterGitTool } from '../../src/main/gitTool' +import { gitMergeScript } from '../../src/main/gitMergeTool' const APP = '/Applications/Diff Bro.app/Contents/MacOS/Diff Bro' // Windows has no exec bit to set or read — registerGitTool already skips the @@ -115,11 +116,41 @@ describe('registerArgs', () => { expect(cmd).toBe('"/bin/diffbro-git" "$LOCAL" "$REMOTE" "$MERGED"') }) - // Diff Bro never writes $MERGED. A trusted exit code would let git mark a - // conflict resolved because the viewer closed cleanly. - it('never lets git trust the exit code of a merge', () => { - const trust = registerArgs('/x').find((a) => a[2].endsWith('trustExitCode')) - expect(trust[3]).toBe('false') + // This USED to be false, because Diff Bro never wrote $MERGED and a trusted + // exit code would have marked a conflict resolved just because the viewer + // closed. It writes the file now, and the merge launcher WAITS for that write + // before exiting — so a clean exit means resolved, and git may believe it. + it('lets git trust the exit code, because the merge launcher waits', () => { + const trust = registerArgs('/x', '/x-merge').find((a) => a[2].endsWith('trustExitCode')) + expect(trust[3]).toBe('true') + }) + + it('points the mergetool at the waiting launcher, not the difftool one', () => { + const args = registerArgs('/bin/diffbro-git', '/bin/diffbro-git-merge') + const merge = args.find((a) => a[2] === `mergetool.${GIT_TOOL_NAME}.cmd`) + expect(merge[3]).toBe('"/bin/diffbro-git-merge" "$LOCAL" "$REMOTE" "$MERGED"') + }) + + // The wait is the whole reason trustExitCode may be true; a launcher that + // returned straight away would hand git a lie. It watches a SENTINEL rather + // than $MERGED itself: a resolution that writes the same bytes back changes + // neither size nor timestamp, and `ls -l` only resolves to the minute. + it('waits for the app to say what it did', () => { + const script = gitMergeScript('/Applications/Diff Bro.app/x') + expect(script).toContain('mergetool') + expect(script).toContain('done_file="$3.diffbro-merge-done"') + expect(script).toContain('while [ ! -f "$done_file" ]') + }) + + // Declining is an answer, and it must not be read as a resolution. + it('fails when the reader cancelled, and succeeds only on a write', () => { + const script = gitMergeScript('/x') + expect(script).toContain('[ "$verdict" = "written" ] || exit 1') + }) + + // A window closed and forgotten must not hold the terminal for ever. + it('gives up rather than waiting without end', () => { + expect(gitMergeScript('/x')).toMatch(/waited" -lt \d+/) }) }) diff --git a/tests/main/mergeGuards.test.js b/tests/main/mergeGuards.test.js new file mode 100644 index 0000000..7f13653 Binary files /dev/null and b/tests/main/mergeGuards.test.js differ diff --git a/tests/main/mergeSession.test.js b/tests/main/mergeSession.test.js new file mode 100644 index 0000000..1f402dc --- /dev/null +++ b/tests/main/mergeSession.test.js @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + beginMerge, + cancelMerge, + doneSentinel, + endMerge, + mergeInProgress, + writeMerged +} from '../../src/main/mergeSession' + +const dirs = [] +const scratch = () => { + const dir = mkdtempSync(join(tmpdir(), 'merge-')) + dirs.push(dir) + return dir +} + +afterEach(() => { + endMerge() + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('writeMerged', () => { + // The whole point of the fence: with no launch there is no path, so there is + // nothing to write over, whatever arrives. + it('writes nothing at all when no mergetool launch is in progress', () => { + expect(writeMerged('anything')).toEqual({ ok: false, error: 'no-merge' }) + }) + + it('writes the text to the path the launch named', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + writeFileSync(merged, '<<<<<<< HEAD\n') + beginMerge({ merged, local: join(dir, 'l'), remote: join(dir, 'r') }) + expect(writeMerged('resolved\n')).toEqual({ ok: true, path: merged }) + expect(readFileSync(merged, 'utf8')).toBe('resolved\n') + }) + + it('refuses anything that is not text', () => { + const dir = scratch() + beginMerge({ merged: join(dir, 'a.js'), local: '', remote: '' }) + expect(writeMerged({ path: '/etc/passwd' })).toEqual({ ok: false, error: 'not-text' }) + expect(writeMerged(null).ok).toBe(false) + }) + + // A second write would be a second file: once the merge is handed back, the + // session is over. + it('is spent once used', () => { + const dir = scratch() + const merged = join(dir, 'a.js') + beginMerge({ merged, local: '', remote: '' }) + expect(writeMerged('one').ok).toBe(true) + expect(writeMerged('two')).toEqual({ ok: false, error: 'no-merge' }) + expect(readFileSync(merged, 'utf8')).toBe('one') + }) + + it('reports a path it cannot write rather than throwing', () => { + beginMerge({ merged: join(scratch(), 'no', 'such', 'dir', 'a.js'), local: '', remote: '' }) + const res = writeMerged('x') + expect(res.ok).toBe(false) + expect(res.error).not.toBe('no-merge') + }) + + it('says whether a merge is waiting, for the window that asks', () => { + expect(mergeInProgress()).toBeNull() + beginMerge({ merged: '/tmp/a', local: '/tmp/l', remote: '/tmp/r' }) + expect(mergeInProgress().merged).toBe('/tmp/a') + endMerge() + expect(mergeInProgress()).toBeNull() + }) +}) + +// The reader declining is an answer. Without it the launcher waited on a +// decision that was never coming, and the path stayed armed for the life of the +// process — so a later writeMerged still overwrote the file. +describe('cancelMerge', () => { + it('spends the session, so a later write does nothing', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + writeFileSync(merged, 'original\n') + beginMerge({ merged, local: '', remote: '' }) + expect(cancelMerge()).toEqual({ ok: true }) + expect(writeMerged('OVERWRITE')).toEqual({ ok: false, error: 'no-merge' }) + expect(readFileSync(merged, 'utf8')).toBe('original\n') + }) + + it('leaves the file itself untouched', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + writeFileSync(merged, '<<<<<<< HEAD\n') + beginMerge({ merged, local: '', remote: '' }) + cancelMerge() + expect(readFileSync(merged, 'utf8')).toBe('<<<<<<< HEAD\n') + }) + + // The launcher is blocked on this file; without it `git mergetool` hangs. + it('releases the launcher, saying which answer it was', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + beginMerge({ merged, local: '', remote: '' }) + cancelMerge() + expect(readFileSync(doneSentinel(merged), 'utf8')).toBe('cancelled') + }) + + it('is a no-op when nothing is in progress', () => { + expect(cancelMerge()).toEqual({ ok: false }) + }) +}) + +describe('the sentinel a successful write leaves', () => { + it('tells the launcher the merge really was written', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + beginMerge({ merged, local: '', remote: '' }) + writeMerged('resolved\n') + expect(readFileSync(doneSentinel(merged), 'utf8')).toBe('written') + }) + + // A resolution that happens to write the SAME bytes back changes neither size + // nor timestamp — which is why the launcher watches a sentinel and not the + // file itself. + it('appears even when the resolved text is identical to the original', () => { + const dir = scratch() + const merged = join(dir, 'app.js') + writeFileSync(merged, 'same\n') + beginMerge({ merged, local: '', remote: '' }) + writeMerged('same\n') + expect(existsSync(doneSentinel(merged))).toBe(true) + }) +}) diff --git a/tests/renderer/stores/diffStore.deps.test.js b/tests/renderer/stores/diffStore.deps.test.js new file mode 100644 index 0000000..988f884 --- /dev/null +++ b/tests/renderer/stores/diffStore.deps.test.js @@ -0,0 +1,115 @@ +// The dependency view: which comparisons offer it, and what it routes to. +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { diffLocks } from '../../../src/renderer/src/utils/lockfile/lockDiff' + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +const lockText = (deps) => + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { dependencies: Object.fromEntries(Object.keys(deps).map((n) => [n, '*'])) }, + ...Object.fromEntries( + Object.entries(deps).map(([n, v]) => [`node_modules/${n}`, { version: v }]) + ) + } + }) + +const load = (diff, left, right, name = 'package-lock.json') => { + diff.left = { path: `/a/${name}`, name, content: left } + diff.right = { path: `/b/${name}`, name, content: right } + diff.mode = 'files' +} + +describe('dependency comparison', () => { + it('offers the view when both sides are the same kind of lockfile', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' })) + expect(diff.canCompareDeps).toBe(true) + }) + + it('refuses when only one side is a lockfile', () => { + const diff = useDiffStore() + diff.left = { path: '/a', name: 'package-lock.json', content: lockText({ vue: '3.4.0' }) } + diff.right = { path: '/b', name: 'notes.txt', content: 'hello' } + diff.mode = 'files' + expect(diff.canCompareDeps).toBe(false) + }) + + // A package-lock against a go.sum is two ecosystems, not a comparison. + it('refuses two lockfiles of different ecosystems', () => { + const diff = useDiffStore() + diff.left = { path: '/a', name: 'package-lock.json', content: lockText({ vue: '3.4.0' }) } + diff.right = { path: '/b', name: 'go.sum', content: 'github.com/a/b v1.0.0 h1:x=\n' } + diff.mode = 'files' + expect(diff.canCompareDeps).toBe(false) + }) + + it('refuses a lockfile that will not parse', () => { + const diff = useDiffStore() + load(diff, '{ not json', lockText({ vue: '3.5.0' })) + expect(diff.canCompareDeps).toBe(false) + }) + + it('routes to the dependency viewer with the toggle on', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' })) + diff.semanticView = false + expect(diff.comparableKind).toBe('text') + diff.semanticView = true + expect(diff.comparableKind).toBe('deps') + }) + + // A lockfile IS json, so the structure view would otherwise claim it — and a + // 780-key tree is not what the reader asked for. + it('wins over the structure view for the same file', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' })) + diff.semanticView = true + expect(diff.canCompareStructure).toBe(true) + expect(diff.comparableKind).toBe('deps') + }) + + it('opens in the dependency view without being asked', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' })) + diff.receive('left', { + path: '/a', + name: 'package-lock.json', + content: lockText({ vue: '3.4.0' }) + }) + expect(diff.semanticView).toBe(true) + }) + + it('names the toggle after what it shows', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' })) + expect(diff.structureLabelKey).toBe('appToolbar.structureDeps') + }) + + // The store answers WHICH lockfiles; the viewer does the comparing. + it('hands the viewer both parsed sides', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0', gone: '1.0.0' }), lockText({ vue: '3.5.0' })) + const { left, right } = diff.lockPair + expect(left.kind).toBe('npm') + expect(diffLocks(left, right).stats).toEqual({ + added: 0, + removed: 1, + bumped: 1, + downgraded: 0 + }) + }) + + it('has no pair to offer when a side is not a lockfile', () => { + const diff = useDiffStore() + load(diff, lockText({ vue: '3.4.0' }), lockText({ vue: '3.5.0' }), 'notes.txt') + expect(diff.lockPair).toBeNull() + }) +}) diff --git a/tests/renderer/utils/lockfile/lockDiff.test.js b/tests/renderer/utils/lockfile/lockDiff.test.js new file mode 100644 index 0000000..947597a --- /dev/null +++ b/tests/renderer/utils/lockfile/lockDiff.test.js @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { diffLocks, semverStep } from '../../../../src/renderer/src/utils/lockfile/lockDiff' + +const pkg = (name, version, extra = {}) => ({ + name, + version, + direct: false, + dev: false, + license: '', + resolved: '', + ...extra +}) +const lock = (packages, extra = {}) => ({ + kind: 'npm', + packages, + knowsDirect: true, + truncated: false, + ...extra +}) +const byName = (rows) => Object.fromEntries(rows.map((r) => [r.name, r])) + +describe('semverStep', () => { + it('names the step between two versions', () => { + expect(semverStep('1.2.3', '2.0.0')).toBe('major') + expect(semverStep('1.2.3', '1.3.0')).toBe('minor') + expect(semverStep('1.2.3', '1.2.4')).toBe('patch') + expect(semverStep('1.2.3', '1.2.3')).toBe('same') + }) + + it('reads a downgrade as the step it went back', () => { + expect(semverStep('2.0.0', '1.9.9')).toBe('major') + expect(semverStep('1.3.0', '1.2.0')).toBe('minor') + }) + + // 0.x is where a minor bump is the breaking one, and a lockfile is full of it. + it('treats a 0.x minor as the breaking step', () => { + expect(semverStep('0.1.0', '0.2.0')).toBe('major') + expect(semverStep('0.1.0', '0.1.1')).toBe('patch') + }) + + it('says nothing it cannot work out', () => { + expect(semverStep('1.2.3', 'v1.2.4-beta.1+build')).toBe('patch') + expect(semverStep('not-a-version', '1.0.0')).toBe('unknown') + expect(semverStep('', '')).toBe('unknown') + }) +}) + +describe('diffLocks', () => { + it('reports nothing when the two sides hold the same set', () => { + const same = lock([pkg('vue', '3.4.21'), pkg('vitest', '4.1.10')]) + const result = diffLocks(same, same) + expect(result.rows).toHaveLength(0) + expect(result.stats).toEqual({ added: 0, removed: 0, bumped: 0, downgraded: 0 }) + expect(result.identical).toBe(true) + }) + + it('classifies added, removed, bumped and downgraded', () => { + const left = lock([pkg('kept', '1.0.0'), pkg('gone', '2.0.0'), pkg('down', '3.1.0')]) + const right = lock([pkg('kept', '1.1.0'), pkg('fresh', '0.1.0'), pkg('down', '3.0.0')]) + const rows = byName(diffLocks(left, right).rows) + expect(rows.kept.status).toBe('bumped') + expect(rows.kept.from).toBe('1.0.0') + expect(rows.kept.to).toBe('1.1.0') + expect(rows.gone.status).toBe('removed') + expect(rows.fresh.status).toBe('added') + expect(rows.down.status).toBe('downgraded') + }) + + it('carries the semver step of a bump', () => { + const left = lock([pkg('a', '1.0.0'), pkg('b', '1.0.0')]) + const right = lock([pkg('a', '2.0.0'), pkg('b', '1.0.1')]) + const rows = byName(diffLocks(left, right).rows) + expect(rows.a.step).toBe('major') + expect(rows.b.step).toBe('patch') + }) + + // The headline the whole view exists for: of everything that moved, how much + // did I ask for? + it('splits the count into what was asked for and what came along', () => { + const left = lock([pkg('asked', '1.0.0', { direct: true }), pkg('carried', '1.0.0')]) + const right = lock([pkg('asked', '2.0.0', { direct: true }), pkg('carried', '1.1.0')]) + const result = diffLocks(left, right) + expect(result.stats.bumped).toBe(2) + expect(result.directCount).toBe(1) + expect(byName(result.rows).asked.direct).toBe(true) + expect(byName(result.rows).carried.direct).toBe(false) + }) + + // A package can be installed at two versions at once. Reporting "bumped" for + // that would invent a move that never happened. + it('handles a package installed at two versions on one side', () => { + const left = lock([pkg('dup', '1.0.0'), pkg('dup', '2.0.0')]) + const right = lock([pkg('dup', '2.0.0')]) + const rows = diffLocks(left, right).rows.filter((r) => r.name === 'dup') + expect(rows).toHaveLength(1) + expect(rows[0].status).toBe('removed') + expect(rows[0].from).toBe('1.0.0') + }) + + it('reports a version arriving beside one that stays as added', () => { + const left = lock([pkg('dup', '1.0.0')]) + const right = lock([pkg('dup', '1.0.0'), pkg('dup', '2.0.0')]) + const rows = diffLocks(left, right).rows.filter((r) => r.name === 'dup') + expect(rows).toHaveLength(1) + expect(rows[0].status).toBe('added') + expect(rows[0].to).toBe('2.0.0') + }) + + // Sorting the leftovers as STRINGS paired 1.10.0 with 1.9.0 and called it a + // downgrade — the same trap moveOf already avoids by comparing triples. + it('pairs multiple versions in version order, not string order', () => { + const left = lock([pkg('dup', '1.9.0'), pkg('dup', '1.10.0')]) + const right = lock([pkg('dup', '1.9.1'), pkg('dup', '1.10.1')]) + const rows = diffLocks(left, right).rows + expect(rows.map((r) => `${r.from}->${r.to}`)).toEqual(['1.9.0->1.9.1', '1.10.0->1.10.1']) + expect(rows.every((r) => r.status === 'bumped')).toBe(true) + }) + + it('sorts what the reader asked for above what came along', () => { + const left = lock([pkg('zzz', '1.0.0', { direct: true }), pkg('aaa', '1.0.0')]) + const right = lock([pkg('zzz', '2.0.0', { direct: true }), pkg('aaa', '2.0.0')]) + expect(diffLocks(left, right).rows.map((r) => r.name)).toEqual(['zzz', 'aaa']) + }) + + it('says when neither side could tell direct from transitive', () => { + const l = lock([pkg('a', '1.0.0')], { knowsDirect: false }) + const r = lock([pkg('a', '2.0.0')], { knowsDirect: false }) + expect(diffLocks(l, r).knowsDirect).toBe(false) + expect(diffLocks(l, r).directCount).toBe(0) + }) + + it('carries dev and licence through to the row', () => { + const left = lock([pkg('a', '1.0.0', { dev: true, license: 'MIT' })]) + const right = lock([pkg('a', '2.0.0', { dev: true, license: 'Apache-2.0' })]) + const row = diffLocks(left, right).rows[0] + expect(row.dev).toBe(true) + expect(row.license).toBe('Apache-2.0') + expect(row.licenseChanged).toBe(true) + }) + + it('survives a side with nothing in it', () => { + const result = diffLocks(lock([]), lock([pkg('a', '1.0.0')])) + expect(result.stats.added).toBe(1) + }) +}) diff --git a/tests/renderer/utils/lockfile/parse.test.js b/tests/renderer/utils/lockfile/parse.test.js new file mode 100644 index 0000000..81b74f7 --- /dev/null +++ b/tests/renderer/utils/lockfile/parse.test.js @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest' +import { parseLockfile, LOCKFILE_KINDS } from '../../../../src/renderer/src/utils/lockfile/parse' + +const byName = (packages) => Object.fromEntries(packages.map((p) => [p.name, p])) + +describe('parseLockfile — npm', () => { + // lockfileVersion 3: every package is a node_modules PATH, and the root entry + // ("") is what says which of them were actually asked for. + const v3 = JSON.stringify({ + name: 'app', + lockfileVersion: 3, + packages: { + '': { name: 'app', dependencies: { vue: '^3.4.0' }, devDependencies: { vitest: '^4.0.0' } }, + 'node_modules/vue': { version: '3.4.21', resolved: 'https://r/vue.tgz', license: 'MIT' }, + 'node_modules/vitest': { version: '4.1.10', dev: true }, + 'node_modules/@vue/shared': { version: '3.4.21' } + } + }) + + it('reads every package with its version', () => { + const lock = parseLockfile(v3, 'package-lock.json') + expect(lock.kind).toBe('npm') + expect(byName(lock.packages).vue.version).toBe('3.4.21') + expect(byName(lock.packages)['@vue/shared'].version).toBe('3.4.21') + }) + + // The whole point of the view: which of these did I ask for? + it('marks the root manifest’s own dependencies as direct', () => { + const p = byName(parseLockfile(v3, 'package-lock.json').packages) + expect(p.vue.direct).toBe(true) + expect(p.vitest.direct).toBe(true) + expect(p['@vue/shared'].direct).toBe(false) + }) + + // A package can be installed twice: once at the root and once nested under a + // dependency that needed a different version. Only the ROOT copy is the one + // the manifest asked for — matching on name alone marked both. + it('does not mark a nested copy of a direct dependency as direct', () => { + const nested = JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { dependencies: { globals: '^14.0.0' } }, + 'node_modules/globals': { version: '14.0.0' }, + 'node_modules/eslint/node_modules/globals': { version: '11.12.0' } + } + }) + const lock = parseLockfile(nested, 'package-lock.json') + const direct = lock.packages.filter((p) => p.direct) + expect(direct).toHaveLength(1) + expect(direct[0].version).toBe('14.0.0') + }) + + it('carries dev and licence where the lockfile states them', () => { + const p = byName(parseLockfile(v3, 'package-lock.json').packages) + expect(p.vitest.dev).toBe(true) + expect(p.vue.dev).toBe(false) + expect(p.vue.license).toBe('MIT') + }) + + // v1 has no `packages` map at all — a nested `dependencies` tree instead. + it('reads a v1 lockfile’s nested tree', () => { + const v1 = JSON.stringify({ + name: 'app', + lockfileVersion: 1, + dependencies: { + vue: { version: '3.4.21', requires: { '@vue/shared': '3.4.21' } }, + '@vue/shared': { version: '3.4.21', dev: true } + } + }) + const lock = parseLockfile(v1, 'package-lock.json') + expect(lock.kind).toBe('npm') + expect(byName(lock.packages).vue.version).toBe('3.4.21') + expect(byName(lock.packages)['@vue/shared'].dev).toBe(true) + }) + + it('reads a nested v1 dependency of a dependency', () => { + const v1 = JSON.stringify({ + lockfileVersion: 1, + dependencies: { + a: { version: '1.0.0', dependencies: { b: { version: '2.0.0' } } } + } + }) + const p = byName(parseLockfile(v1, 'package-lock.json').packages) + expect(p.b.version).toBe('2.0.0') + }) +}) + +describe('parseLockfile — pnpm', () => { + const yaml = `lockfileVersion: '9.0' +importers: + .: + dependencies: + vue: + specifier: ^3.4.0 + version: 3.4.21 + devDependencies: + vitest: + specifier: ^4.0.0 + version: 4.1.10 +packages: + vue@3.4.21: + resolution: {integrity: sha512-aaa} + vitest@4.1.10: + resolution: {integrity: sha512-bbb} + '@vue/shared@3.4.21': + resolution: {integrity: sha512-ccc} +` + + it('reads packages out of the key, version and all', () => { + const lock = parseLockfile(yaml, 'pnpm-lock.yaml') + expect(lock.kind).toBe('pnpm') + const p = byName(lock.packages) + expect(p.vue.version).toBe('3.4.21') + expect(p['@vue/shared'].version).toBe('3.4.21') + }) + + it('takes direct dependencies from the importers block', () => { + const p = byName(parseLockfile(yaml, 'pnpm-lock.yaml').packages) + expect(p.vue.direct).toBe(true) + expect(p.vitest.direct).toBe(true) + expect(p.vitest.dev).toBe(true) + expect(p['@vue/shared'].direct).toBe(false) + }) +}) + +describe('parseLockfile — yarn v1', () => { + const yarn = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +vue@^3.4.0: + version "3.4.21" + resolved "https://r/vue.tgz#abc" + integrity sha512-aaa + +"@vue/shared@3.4.21": + version "3.4.21" + resolved "https://r/shared.tgz#def" + +lodash@^4.0.0, lodash@^4.17.0: + version "4.17.21" +` + + it('reads a package, a scoped one and a multi-spec entry', () => { + const lock = parseLockfile(yarn, 'yarn.lock') + expect(lock.kind).toBe('yarn') + const p = byName(lock.packages) + expect(p.vue.version).toBe('3.4.21') + expect(p['@vue/shared'].version).toBe('3.4.21') + expect(p.lodash.version).toBe('4.17.21') + }) + + // A v1 lockfile does not say which were asked for; claiming otherwise would be + // a guess rendered as a fact. + it('claims nothing about direct dependencies', () => { + const lock = parseLockfile(yarn, 'yarn.lock') + expect(lock.knowsDirect).toBe(false) + expect(lock.packages.every((p) => p.direct === false)).toBe(true) + }) +}) + +describe('parseLockfile — go.sum', () => { + const gosum = `github.com/foo/bar v1.2.3 h1:aaa= +github.com/foo/bar v1.2.3/go.mod h1:bbb= +github.com/baz/qux v0.9.0 h1:ccc= +github.com/baz/qux v0.9.0/go.mod h1:ddd= +` + + it('reads one entry per module, not one per hash line', () => { + const lock = parseLockfile(gosum, 'go.sum') + expect(lock.kind).toBe('go') + expect(lock.packages).toHaveLength(2) + expect(byName(lock.packages)['github.com/foo/bar'].version).toBe('v1.2.3') + }) +}) + +describe('parseLockfile — composer', () => { + const composer = JSON.stringify({ + 'content-hash': 'abc', + packages: [ + { + name: 'monolog/monolog', + version: '3.5.0', + license: ['MIT'], + dist: { url: 'https://d/m.zip' } + }, + { name: 'psr/log', version: 'v3.0.0', license: ['MIT', 'Apache-2.0'] } + ], + 'packages-dev': [{ name: 'phpunit/phpunit', version: '10.5.0', license: ['BSD-3-Clause'] }] + }) + + it('reads both arrays and marks the dev one', () => { + const lock = parseLockfile(composer, 'composer.lock') + expect(lock.kind).toBe('composer') + const p = byName(lock.packages) + expect(p['monolog/monolog'].version).toBe('3.5.0') + expect(p['psr/log'].version).toBe('v3.0.0') + expect(p['phpunit/phpunit'].dev).toBe(true) + expect(p['monolog/monolog'].dev).toBe(false) + }) + + it('joins a dual licence rather than showing the first', () => { + const p = byName(parseLockfile(composer, 'composer.lock').packages) + expect(p['psr/log'].license).toBe('MIT, Apache-2.0') + }) + + // composer.lock is the resolved set; composer.json is what was asked for, and + // it is a different file. + it('claims nothing about direct dependencies', () => { + const lock = parseLockfile(composer, 'composer.lock') + expect(lock.knowsDirect).toBe(false) + expect(lock.packages.every((p) => p.direct === false)).toBe(true) + }) + + it('reads a lockfile with only dev packages', () => { + const devOnly = JSON.stringify({ + packages: [], + 'packages-dev': [{ name: 'a/b', version: '1.0.0' }] + }) + expect(parseLockfile(devOnly, 'composer.lock').packages).toHaveLength(1) + }) +}) + +describe('parseLockfile — what it refuses', () => { + it('returns null for a name it does not recognise', () => { + expect(parseLockfile('{}', 'tsconfig.json')).toBeNull() + }) + + // Untrusted input: a lockfile is a file someone handed you. It degrades to the + // text view rather than throwing. + it('returns null rather than throwing on malformed content', () => { + expect(parseLockfile('{ not json', 'package-lock.json')).toBeNull() + expect(parseLockfile('', 'pnpm-lock.yaml')).toBeNull() + expect(parseLockfile('', 'yarn.lock')).toBeNull() + }) + + it('returns null when the shape is right but there are no packages', () => { + expect(parseLockfile(JSON.stringify({ lockfileVersion: 3 }), 'package-lock.json')).toBeNull() + }) + + it('caps a lockfile that claims more packages than anyone has', () => { + const packages = { '': {} } + for (let i = 0; i < 60000; i++) packages[`node_modules/p${i}`] = { version: '1.0.0' } + const lock = parseLockfile( + JSON.stringify({ lockfileVersion: 3, packages }), + 'package-lock.json' + ) + expect(lock.truncated).toBe(true) + expect(lock.packages.length).toBeLessThanOrEqual(50000) + }) + + it('recognises every kind it claims to', () => { + expect(LOCKFILE_KINDS.map((k) => k.name)).toEqual([ + 'package-lock.json', + 'npm-shrinkwrap.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'go.sum', + 'composer.lock' + ]) + }) +}) diff --git a/tests/renderer/utils/mergeConflicts.test.js b/tests/renderer/utils/mergeConflicts.test.js new file mode 100644 index 0000000..9595f04 --- /dev/null +++ b/tests/renderer/utils/mergeConflicts.test.js @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + composeMerge, + conflictCount, + parseConflicts, + unresolvedCount +} from '../../../src/renderer/src/utils/mergeConflicts' + +const FILE = [ + 'top', + '<<<<<<< HEAD', + 'ours one', + 'ours two', + '=======', + 'theirs one', + '>>>>>>> feature', + 'bottom' +].join('\n') + +const DIFF3 = [ + '<<<<<<< HEAD', + 'ours', + '||||||| merged common ancestors', + 'base', + '=======', + 'theirs', + '>>>>>>> feature' +].join('\n') + +describe('parseConflicts', () => { + it('splits a file into stable text and the conflicts between it', () => { + const parsed = parseConflicts(FILE) + expect(parsed.segments.map((s) => s.type)).toEqual(['stable', 'conflict', 'stable']) + expect(parsed.segments[0].lines).toEqual(['top']) + expect(parsed.segments[2].lines).toEqual(['bottom']) + }) + + it('reads both sides of a conflict, and what each was called', () => { + const [, conflict] = parseConflicts(FILE).segments + expect(conflict.ours).toEqual(['ours one', 'ours two']) + expect(conflict.theirs).toEqual(['theirs one']) + expect(conflict.oursLabel).toBe('HEAD') + expect(conflict.theirsLabel).toBe('feature') + }) + + // diff3 style adds the common ancestor, which is the only thing that says + // WHICH side changed. + it('reads the base section when the file carries one', () => { + const [conflict] = parseConflicts(DIFF3).segments + expect(conflict.base).toEqual(['base']) + expect(conflict.ours).toEqual(['ours']) + expect(conflict.theirs).toEqual(['theirs']) + }) + + it('has no base when the file was written without one', () => { + expect(parseConflicts(FILE).segments[1].base).toBeNull() + }) + + it('counts what there is to resolve', () => { + expect(conflictCount(parseConflicts(FILE))).toBe(1) + const two = parseConflicts([FILE, FILE].join('\n')) + expect(conflictCount(two)).toBe(2) + }) + + it('reports a file with no conflicts at all', () => { + const parsed = parseConflicts('just\ntext\n') + expect(conflictCount(parsed)).toBe(0) + expect(parsed.segments).toHaveLength(1) + }) + + // A file whose markers do not close is not a conflict file; treating the rest + // as "ours" would silently drop the other side. + it('refuses a truncated conflict rather than guessing', () => { + expect(parseConflicts('<<<<<<< HEAD\nours\n')).toBeNull() + expect(parseConflicts('<<<<<<< HEAD\nours\n=======\ntheirs\n')).toBeNull() + }) + + it('keeps a line that merely looks like a marker inside a resolved region', () => { + const parsed = parseConflicts('a\n<<<<<< { + const parsed = parseConflicts(FILE) + + it('takes our side', () => { + expect(composeMerge(parsed, ['ours'])).toBe('top\nours one\nours two\nbottom') + }) + + it('takes their side', () => { + expect(composeMerge(parsed, ['theirs'])).toBe('top\ntheirs one\nbottom') + }) + + it('takes both, ours first — the order the file had them in', () => { + expect(composeMerge(parsed, ['both'])).toBe('top\nours one\nours two\ntheirs one\nbottom') + }) + + it('takes neither, leaving the surrounding text joined', () => { + expect(composeMerge(parsed, ['neither'])).toBe('top\nbottom') + }) + + // Writing a half-resolved file would hand git a merge with markers still in + // it, which is worse than not writing at all. + it('refuses while any conflict is unresolved', () => { + expect(composeMerge(parsed, [null])).toBeNull() + expect(composeMerge(parsed, [])).toBeNull() + }) + + it('resolves each conflict independently', () => { + const two = parseConflicts([FILE, FILE].join('\n')) + expect(unresolvedCount(two, ['ours'])).toBe(1) + const text = composeMerge(two, ['ours', 'theirs']) + expect(text).toContain('ours one') + expect(text).toContain('theirs one') + }) + + it('keeps the file’s trailing newline', () => { + const withEol = parseConflicts(`${FILE}\n`) + expect(composeMerge(withEol, ['ours']).endsWith('\n')).toBe(true) + }) + + it('keeps CRLF where the file used it', () => { + const crlf = parseConflicts(FILE.split('\n').join('\r\n')) + expect(composeMerge(crlf, ['ours'])).toBe('top\r\nours one\r\nours two\r\nbottom') + }) +}) + +// git genuinely produces files whose markers do not use the file's dominant +// line ending — a CRLF file gets LF markers. Picking one EOL for the whole file +// found ZERO conflicts in one, so the dialog said "nothing left to decide" and +// writing it back put the markers straight back on disk. +describe('parseConflicts — a file whose line endings are mixed', () => { + const MIXED = 'header\r\none\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\ntail\n' + + it('finds the conflict whatever the surrounding line endings are', () => { + const parsed = parseConflicts(MIXED) + expect(conflictCount(parsed)).toBe(1) + expect(parsed.segments[1].ours).toEqual(['ours']) + expect(parsed.segments[1].theirs).toEqual(['theirs']) + }) + + it('gives every line back exactly the ending it had', () => { + const parsed = parseConflicts(MIXED) + expect(composeMerge(parsed, ['ours'])).toBe('header\r\none\nours\ntail\n') + }) + + it('round-trips a file it changes nothing in', () => { + const plain = 'a\r\nb\nc' + expect(composeMerge(parseConflicts(plain), [])).toBe(plain) + }) +})