From b4f0be3d7783ef650130676bb6d29f8600fd8cba Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:44:26 +0200 Subject: [PATCH] Update to 4.4 --- CHANGELOG.md | 7 ++++ SOURCE_MANIFEST.txt | 13 +++---- docs/RELEASE_NOTES_0.4.4.md | 9 +++++ package-lock.json | 4 +-- package.json | 7 ++-- scripts/verify.mjs | 8 ++--- src/main/git-service.cjs | 31 ++++++++++++---- src/renderer/app.js | 22 ++++++++++-- tests/git-integration.test.mjs | 66 ++++++++++++++++++++++++++++++++++ 9 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.4.4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c983d..19b47e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.4.4 + +- Correctly stage deleted and renamed paths. +- Preserve and surface local commits when push fails. +- Always refresh the actual Git state after operation errors. +- Add end-to-end Git regression coverage for deletion and push recovery. + ## 0.4.3 - Removed the platform-dependent Unraid inspection integration test that mixed Windows temporary paths with remote Linux semantics. diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 7f421db..daccd47 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,5 +1,5 @@ 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore -4bf1a097cf93b11eb49c09a9d7f92a341eab15c5eafd7014f6087aac352648ca CHANGELOG.md +769628bb17f93cbe8718c9c0aabb4ef5a2e2d478ff8a8814705bdb523fa341f4 CHANGELOG.md 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md @@ -26,6 +26,7 @@ bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_N 343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md 1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md +85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md @@ -47,17 +48,17 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json 6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs -4a3d70ddcb00778d98b38662fa6af27ce33017e95139a2726298e3a20948985e package.json +9c94d27cfba4d45b1ac1c044ccf6816651aa75d99e964a3ee58fbc54dde94a11 package.json 1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1 f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs -d8c56b85186f36579e8b311f929db2a7c7b77f817cc95ea953fceaba0467a8ed scripts/verify.mjs +bf6557aa692756928d4ded6ff9c2556c2c8ca955c25dd940c8ba918be85779b1 scripts/verify.mjs 92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1 366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs -620226c8518c8308e0095f17102371238aeb8db92005392fe1ed0163d2e567b5 src/main/git-service.cjs +aa5316854b01fff17b1b73959b6124a5bc4062376f7b622dee70975d27becae7 src/main/git-service.cjs ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs 2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs @@ -68,7 +69,7 @@ eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repos 65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs 2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs -5fe59bb1a3aa7afea4694891dc32836c7a15d0fa42a3d87bf7217365ee97d95b src/renderer/app.js +63e5b88b5b0a1801ec21d889a7c29ac70767877599c3155e2b06e548f35c4d9e src/renderer/app.js 2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png 37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png 0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html @@ -85,7 +86,7 @@ a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/val 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs -7ed195eba8f40ea1e4a57678b0e3dd2defbe888c5c8aab5e931898d506ad638e tests/git-integration.test.mjs +45c01a557811bf839c4dd06d3828cd72771969f3b81849629abc2986c55382b4 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs diff --git a/docs/RELEASE_NOTES_0.4.4.md b/docs/RELEASE_NOTES_0.4.4.md new file mode 100644 index 0000000..22bd2f1 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.4.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.4.4 + +## Correct Git change handling + +- Deleted files are staged with `git add -A` and are removed from Gitea after commit and push. +- Renames include both the new path and original path when staging or unstaging selected changes. +- A failed staging action always reloads the real repository state so changes remain visible. +- When commit succeeds but push failed, ForgeFlow clears obsolete file selection and shows the clean working tree as an ahead branch with a dedicated retry push action. +- Added real bare-remote regression tests for deleted files, renames, and push-after-commit failure recovery. diff --git a/package-lock.json b/package-lock.json index a2b79b7..0bd49be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.4.3", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.4.3", + "version": "0.4.4", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index c79d3e6..8e52262 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.4.3", + "version": "0.4.4", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -85,5 +85,8 @@ "author": "Jens", "dependencies": { "ssh2": "1.17.0" - } + }, + "files": [ + "docs/RELEASE_NOTES_0.4.4.md" + ] } diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 441b420..8c8560a 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -18,7 +18,7 @@ const required = [ 'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', 'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', - 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', + 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', 'docs/RELEASE_NOTES_0.4.4.md', 'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', 'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', 'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json', @@ -28,7 +28,7 @@ const required = [ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); -if (packageJson.version !== '0.4.3') throw new Error(`Expected package version 0.4.3, got ${packageJson.version}.`); +if (packageJson.version !== '0.4.4') throw new Error(`Expected package version 0.4.4, got ${packageJson.version}.`); for (const group of ['dependencies', 'devDependencies']) { for (const [name, version] of Object.entries(packageJson[group] || {})) { if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`); @@ -67,7 +67,7 @@ JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json') const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8'); const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); -const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.3.md'), 'utf8'); +const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.4.md'), 'utf8'); if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) { throw new Error('Setup guide is missing required connection or diagnostics instructions.'); } @@ -77,7 +77,7 @@ if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fing if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) { throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.'); } -for (const phrase of ['platform-independent', 'SSH inspection', 'Windows', 'Regression coverage']) { +for (const phrase of ['deleted files', 'git add -A', 'push failed', 'ahead branch']) { if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); } const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8'); diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs index b9f7931..5243762 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -73,21 +73,36 @@ class GitService { return result.stdout; } + async expandSelectedPaths(root, files) { + const selected = assertRepositoryRelativePaths(files); + if (!selected.length) return []; + const status = await this.status(root); + const expanded = new Set(selected); + for (const file of status.files) { + if (selected.includes(file.path) || (file.originalPath && selected.includes(file.originalPath))) { + expanded.add(file.path); + if (file.originalPath) expanded.add(file.originalPath); + } + } + return [...expanded]; + } + async stage(repoPath, files) { const root = await this.ensureRepository(repoPath); - const selected = assertRepositoryRelativePaths(files); - await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 }); + const selected = await this.expandSelectedPaths(root, files); + // -A is required for deleted files and for the old side of renames. + await run('git', selected.length ? ['add', '-A', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 }); return this.status(root); } async unstage(repoPath, files) { const root = await this.ensureRepository(repoPath); - const selected = assertRepositoryRelativePaths(files); + const selected = await this.expandSelectedPaths(root, files); const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] }); if (hasHead.exitCode === 0) { await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root }); } else { - await run('git', selected.length ? ['rm', '--cached', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); + await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); } return this.status(root); } @@ -95,9 +110,11 @@ class GitService { async prepareSelectedStage(root, files) { const selected = assertRepositoryRelativePaths(files); if (selected.length) { - const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root }); - const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean); - const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file)); + const current = await this.status(root); + const excludedStaged = current.files + .filter((file) => file.staged) + .filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath))) + .map((file) => file.path); if (excludedStaged.length) { throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`); } diff --git a/src/renderer/app.js b/src/renderer/app.js index e96bf3b..84933d4 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -202,6 +202,14 @@ async function refreshRepositories(withLoader = true, silent = false) { if (selectedId && !selectedRepository()) ui.selectedRepoId = null; const repository = selectedRepository(); if (repository && !repository.deploymentProfiles.some((profile) => profile.id === ui.selectedProfileId)) ui.selectedProfileId = selectedProfile(repository)?.id || null; + if (repository) { + const availablePaths = new Set((repository.localStatus?.files || []).map((file) => file.path)); + ui.selectedFiles = new Set([...ui.selectedFiles].filter((filePath) => availablePaths.has(filePath))); + if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) { + ui.selectedFile = repository.localStatus?.files?.[0]?.path || null; + ui.diff = ''; + } + } if (!ui.selectedRepoId && ui.currentView === 'repository' && ui.repositories.length) selectRepository(ui.repositories[0].id, false); } catch (error) { ui.refreshError = error.message; @@ -655,8 +663,18 @@ async function runOperation(message, operation, successMessage, { refresh = true if (refresh) await refreshRepositories(false); return result; } catch (error) { - showToast(error.code === 'PUSH_AFTER_COMMIT_FAILED' ? 'Commit created; push failed' : 'Operation failed', error.message, 'error'); - if (error.commitSha) await refreshRepositories(false, true); + const pushAfterCommit = error.code === 'PUSH_AFTER_COMMIT_FAILED'; + showToast(pushAfterCommit ? 'Commit saved locally; push failed' : 'Operation failed', error.message, 'error'); + // Always reload the real Git state. A failed stage must keep changes visible, while a + // failed push after a successful commit must immediately surface as an ahead branch. + await refreshRepositories(false, true); + if (pushAfterCommit) { + ui.selectedFiles.clear(); + ui.selectedFile = null; + ui.diff = ''; + ui.commitMessage = ''; + render(); + } return null; } finally { setLoading(false); } } diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs index dde12d9..b78dc30 100644 --- a/tests/git-integration.test.mjs +++ b/tests/git-integration.test.mjs @@ -52,3 +52,69 @@ test('GitService reads changes and commits/pushes selected files to a real bare const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root); assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy'); }); + +test('stages and pushes deleted and renamed files selected from the working tree', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'silent-zebra-glow.zip'), 'obsolete archive\n'); + await fs.writeFile(path.join(working, 'old-name.txt'), 'rename me\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial files'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.rm(path.join(working, 'silent-zebra-glow.zip')); + await fs.rename(path.join(working, 'old-name.txt'), path.join(working, 'new-name.txt')); + + const service = new GitService(); + const before = await service.status(working); + assert.ok(before.files.some((file) => file.path === 'silent-zebra-glow.zip' && file.status === 'deleted')); + + const selected = before.files.map((file) => file.path); + const result = await service.commitAndPush(working, 'Remove obsolete archive and rename file', selected); + assert.equal(result.status.clean, true); + assert.equal(result.status.branch.ahead, 0); + + const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root); + const names = tree.stdout.trim().split(/\r?\n/).filter(Boolean); + assert.deepEqual(names, ['new-name.txt']); +}); + +test('keeps a successful local commit visible as ahead when the following push fails', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-push-failure-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), '# Portfolio\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.appendFile(path.join(working, 'README.md'), '\nUpdated locally.\n'); + await git(['remote', 'set-url', 'origin', path.join(root, 'missing-remote.git')], working); + + const service = new GitService(); + await assert.rejects( + service.commitAndPush(working, 'Update portfolio', ['README.md']), + (error) => Boolean(error.code === 'PUSH_AFTER_COMMIT_FAILED' && error.commitSha) + ); + + const status = await service.status(working); + assert.equal(status.clean, true); + assert.equal(status.branch.ahead, 1); + const subject = await git(['log', '-1', '--pretty=%s'], working); + assert.equal(subject.stdout.trim(), 'Update portfolio'); +});