diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b47e1..da34959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,3 +71,8 @@ - Added dark/light themes, onboarding and browser demo. - Added unit tests and a real temporary Git remote integration test. - Added safe workflow and server deployment examples. + +## 0.4.5 +- Fixed commit/push after manually staging a deleted file. +- Already staged deletions and renames are no longer re-added as missing pathspecs. +- Added a real bare-remote regression test for `silent-zebra-glow.zip`. diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index daccd47..15e63e2 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,5 +1,5 @@ 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore -769628bb17f93cbe8718c9c0aabb4ef5a2e2d478ff8a8814705bdb523fa341f4 CHANGELOG.md +af25ba17d5e51503943c84366c724bf6f32f4b61e4985e4ffd78e1bb54197493 CHANGELOG.md 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md @@ -27,6 +27,7 @@ bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_N e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md 1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md 85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md +5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md @@ -48,17 +49,17 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json 6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs -9c94d27cfba4d45b1ac1c044ccf6816651aa75d99e964a3ee58fbc54dde94a11 package.json +e5bf62b9bef693dfed707a7771d7a25dab3e781ed52223beadf3322b9c1f53be package.json 1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1 f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs -bf6557aa692756928d4ded6ff9c2556c2c8ca955c25dd940c8ba918be85779b1 scripts/verify.mjs +13d27b4dc4217a7e77d69056f23c46382b2bcd6b0642c2f25a743ace8283e5dc 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 -aa5316854b01fff17b1b73959b6124a5bc4062376f7b622dee70975d27becae7 src/main/git-service.cjs +a921c1c3a70ffff78f208b69431c525393bdb524d61ab484e85372f1b7d176dc src/main/git-service.cjs ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs 2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs @@ -86,7 +87,7 @@ a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/val 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs -45c01a557811bf839c4dd06d3828cd72771969f3b81849629abc2986c55382b4 tests/git-integration.test.mjs +64f3736e8576536221307a0571ea2769893106a6b6b9887f51f5279312b0a0eb 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.5.md b/docs/RELEASE_NOTES_0.4.5.md new file mode 100644 index 0000000..e669d43 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.5.md @@ -0,0 +1,8 @@ +# ForgeFlow 0.4.5 + +## Git staging correctness + +- Fixes committing and pushing a deleted file after it was already staged manually. +- Already staged deletions and renames are no longer passed to `git add -A` a second time. +- Only selected records that still contain unstaged worktree changes are restaged. +- Adds a real bare-remote regression test using `silent-zebra-glow.zip`. diff --git a/package-lock.json b/package-lock.json index 0bd49be..7a3edfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.4.4", + "version": "0.4.5", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 8e52262..983171a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.4.4", + "version": "0.4.5", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 8c8560a..0c2d566 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/RELEASE_NOTES_0.4.4.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', 'docs/RELEASE_NOTES_0.4.5.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.4') throw new Error(`Expected package version 0.4.4, got ${packageJson.version}.`); +if (packageJson.version !== '0.4.5') throw new Error(`Expected package version 0.4.5, 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.4.md'), 'utf8'); +const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.5.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 ['deleted files', 'git add -A', 'push failed', 'ahead branch']) { +for (const phrase of ['already staged', 'git add -A', 'silent-zebra-glow.zip']) { 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 5243762..c51d6b6 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -73,25 +73,44 @@ class GitService { return result.stdout; } - async expandSelectedPaths(root, files) { + selectedStatusFiles(status, files) { const selected = assertRepositoryRelativePaths(files); - if (!selected.length) return []; + if (!selected.length) return { selected, matches: status.files }; + const selectedSet = new Set(selected); + const matches = status.files.filter((file) => selectedSet.has(file.path) || (file.originalPath && selectedSet.has(file.originalPath))); + return { selected, matches }; + } + + async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) { 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); - } + const { selected, matches } = this.selectedStatusFiles(status, files); + if (!selected.length) return []; + const expanded = new Set(); + for (const file of matches) { + if (unstagedOnly && !file.unstaged) continue; + 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 = 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 }); + const requested = assertRepositoryRelativePaths(files); + if (!requested.length) { + await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 }); + return this.status(root); + } + + // Only stage records that still have a worktree-side change. Re-running + // `git add -A -- deleted-file` after that deletion is already staged makes + // Git fail with "pathspec did not match any files" because the file no + // longer exists in either the worktree or HEAD. Staged-only deletions and + // renames are already ready for commit and must therefore be left alone. + const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true }); + if (selected.length) { + await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 }); + } return this.status(root); } diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs index b78dc30..d812fdb 100644 --- a/tests/git-integration.test.mjs +++ b/tests/git-integration.test.mjs @@ -87,6 +87,40 @@ test('stages and pushes deleted and renamed files selected from the working tree assert.deepEqual(names, ['new-name.txt']); }); + +test('commits a deletion that was already staged manually without restaging its missing path', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-staged-delete-')); + 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 git(['add', '.'], working); + await git(['commit', '-m', 'Initial archive'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.rm(path.join(working, 'silent-zebra-glow.zip')); + const service = new GitService(); + const staged = await service.stage(working, ['silent-zebra-glow.zip']); + assert.equal(staged.files[0].path, 'silent-zebra-glow.zip'); + assert.equal(staged.files[0].staged, true); + assert.equal(staged.files[0].unstaged, false); + + // This used to call git add -A for the same already-staged deletion again, + // which fails with a pathspec error because the file no longer exists. + const result = await service.commitAndPush(working, 'Remove obsolete archive', ['silent-zebra-glow.zip']); + 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); + assert.equal(tree.stdout.trim(), ''); +}); + 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 }));