Update to 0.4.5

This commit is contained in:
NuklearRabbit
2026-07-24 20:50:37 +02:00
parent b4f0be3d77
commit 602309b203
8 changed files with 90 additions and 23 deletions
+5
View File
@@ -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`.
+6 -5
View File
@@ -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
+8
View File
@@ -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`.
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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');
+30 -11
View File
@@ -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);
}
+34
View File
@@ -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 }));