Update to 4.4
This commit is contained in:
@@ -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
|
## 0.4.3
|
||||||
|
|
||||||
- Removed the platform-dependent Unraid inspection integration test that mixed Windows temporary paths with remote Linux semantics.
|
- Removed the platform-dependent Unraid inspection integration test that mixed Windows temporary paths with remote Linux semantics.
|
||||||
|
|||||||
+7
-6
@@ -1,5 +1,5 @@
|
|||||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
|
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
|
||||||
4bf1a097cf93b11eb49c09a9d7f92a341eab15c5eafd7014f6087aac352648ca CHANGELOG.md
|
769628bb17f93cbe8718c9c0aabb4ef5a2e2d478ff8a8814705bdb523fa341f4 CHANGELOG.md
|
||||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
|
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
|
||||||
f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md
|
f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md
|
||||||
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
|
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
|
||||||
@@ -26,6 +26,7 @@ bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_N
|
|||||||
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md
|
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md
|
||||||
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md
|
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md
|
||||||
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
|
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
|
||||||
|
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md
|
||||||
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
|
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
|
||||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
|
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
|
||||||
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
|
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
|
||||||
@@ -47,17 +48,17 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea
|
|||||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
|
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
|
||||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
|
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
|
||||||
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
|
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
|
||||||
4a3d70ddcb00778d98b38662fa6af27ce33017e95139a2726298e3a20948985e package.json
|
9c94d27cfba4d45b1ac1c044ccf6816651aa75d99e964a3ee58fbc54dde94a11 package.json
|
||||||
1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs
|
1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs
|
||||||
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
|
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
|
||||||
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
|
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
|
||||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
|
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
|
||||||
d8c56b85186f36579e8b311f929db2a7c7b77f817cc95ea953fceaba0467a8ed scripts/verify.mjs
|
bf6557aa692756928d4ded6ff9c2556c2c8ca955c25dd940c8ba918be85779b1 scripts/verify.mjs
|
||||||
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1
|
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1
|
||||||
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs
|
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs
|
||||||
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs
|
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs
|
||||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-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
|
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs
|
||||||
2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs
|
2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs
|
||||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
|
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
|
||||||
@@ -68,7 +69,7 @@ eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repos
|
|||||||
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs
|
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs
|
||||||
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs
|
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs
|
||||||
2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-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
|
2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png
|
||||||
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png
|
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png
|
||||||
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html
|
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html
|
||||||
@@ -85,7 +86,7 @@ a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/val
|
|||||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs
|
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs
|
||||||
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs
|
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs
|
||||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.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
|
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs
|
||||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs
|
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs
|
||||||
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs
|
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||||
"main": "main.cjs",
|
"main": "main.cjs",
|
||||||
@@ -85,5 +85,8 @@
|
|||||||
"author": "Jens",
|
"author": "Jens",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"docs/RELEASE_NOTES_0.4.4.md"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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',
|
'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/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/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/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
|
||||||
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
|
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
|
||||||
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
|
'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));
|
for (const file of required) await access(path.join(root, file));
|
||||||
|
|
||||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
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 group of ['dependencies', 'devDependencies']) {
|
||||||
for (const [name, version] of Object.entries(packageJson[group] || {})) {
|
for (const [name, version] of Object.entries(packageJson[group] || {})) {
|
||||||
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
|
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 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 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 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')) {
|
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
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/')) {
|
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
|
||||||
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
|
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}`);
|
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
|
||||||
}
|
}
|
||||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||||
|
|||||||
@@ -73,21 +73,36 @@ class GitService {
|
|||||||
return result.stdout;
|
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) {
|
async stage(repoPath, files) {
|
||||||
const root = await this.ensureRepository(repoPath);
|
const root = await this.ensureRepository(repoPath);
|
||||||
const selected = assertRepositoryRelativePaths(files);
|
const selected = await this.expandSelectedPaths(root, files);
|
||||||
await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 });
|
// -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);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unstage(repoPath, files) {
|
async unstage(repoPath, files) {
|
||||||
const root = await this.ensureRepository(repoPath);
|
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] });
|
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
|
||||||
if (hasHead.exitCode === 0) {
|
if (hasHead.exitCode === 0) {
|
||||||
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
|
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
|
||||||
} else {
|
} 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);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
@@ -95,9 +110,11 @@ class GitService {
|
|||||||
async prepareSelectedStage(root, files) {
|
async prepareSelectedStage(root, files) {
|
||||||
const selected = assertRepositoryRelativePaths(files);
|
const selected = assertRepositoryRelativePaths(files);
|
||||||
if (selected.length) {
|
if (selected.length) {
|
||||||
const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root });
|
const current = await this.status(root);
|
||||||
const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean);
|
const excludedStaged = current.files
|
||||||
const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file));
|
.filter((file) => file.staged)
|
||||||
|
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
||||||
|
.map((file) => file.path);
|
||||||
if (excludedStaged.length) {
|
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.`);
|
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -202,6 +202,14 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
|||||||
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
||||||
const repository = selectedRepository();
|
const repository = selectedRepository();
|
||||||
if (repository && !repository.deploymentProfiles.some((profile) => profile.id === ui.selectedProfileId)) ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
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);
|
if (!ui.selectedRepoId && ui.currentView === 'repository' && ui.repositories.length) selectRepository(ui.repositories[0].id, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ui.refreshError = error.message;
|
ui.refreshError = error.message;
|
||||||
@@ -655,8 +663,18 @@ async function runOperation(message, operation, successMessage, { refresh = true
|
|||||||
if (refresh) await refreshRepositories(false);
|
if (refresh) await refreshRepositories(false);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.code === 'PUSH_AFTER_COMMIT_FAILED' ? 'Commit created; push failed' : 'Operation failed', error.message, 'error');
|
const pushAfterCommit = error.code === 'PUSH_AFTER_COMMIT_FAILED';
|
||||||
if (error.commitSha) await refreshRepositories(false, true);
|
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;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root);
|
||||||
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user