diff --git a/src/containers/job/JobManagerContainer.ts b/src/containers/job/JobManagerContainer.ts index ff0d9f11..d6e24b69 100644 --- a/src/containers/job/JobManagerContainer.ts +++ b/src/containers/job/JobManagerContainer.ts @@ -689,6 +689,59 @@ export class JobManagerContainer extends Container { const userConfigService = new UserConfigService(googleFileSystem); const userConfig = await userConfigService.load(); + // Check if local branch is behind remote and sync if needed + if (userConfig.remote_branch) { + try { + await gitScanner.fetch({ + privateKeyFile: await userConfigService.getDeployPrivateKeyPath() + }); + + const { ahead, behind } = await gitScanner.countAheadBehind(userConfig.remote_branch); + + if (ahead > 0 && behind > 0) { + throw new Error('Local and remote branches have diverged. Please manually sync your repository before committing.'); + } + + if (behind > 0) { + logger.info(`Local branch is ${behind} commit(s) behind remote. Syncing before commit...`); + + // Stash local changes - returns true if something was stashed + const stashed = await gitScanner.stashChanges(); + + try { + // Pull with rebase to integrate remote changes (uses git pull --rebase internally) + await gitScanner.pullBranch(userConfig.remote_branch, { + privateKeyFile: await userConfigService.getDeployPrivateKeyPath() + }); + + // Apply stashed changes if we stashed something + if (stashed) { + await gitScanner.stashPop(); + + // Check for conflicts after stash pop + if (await gitScanner.hasConflicts()) { + throw new Error('Stash pop resulted in merge conflicts. Cannot proceed with commit. ' + + 'Please resolve conflicts manually using "Reset and Pull" or by running git commands directly.'); + } + } + } catch (err) { + // If pull fails, leave stash intact for manual recovery + // The user can use "Reset and Pull" to clean up or `git stash list` to view saved changes + if (stashed) { + logger.warn('Pull failed. Stashed changes remain saved for manual recovery. ' + + 'Use `git stash list` to view stashed changes or "Reset and Pull" to clean up.'); + } + throw err; + } + } + } catch (err) { + if (err.message.indexOf('Failed to retrieve list of SSH authentication methods') > -1) { + throw new Error('Failed to authenticate with remote repository: ' + err.message); + } + throw err; + } + } + const contentFileService = await getContentFileService(transformedFileSystem, userConfigService); const markdownTreeProcessor = new MarkdownTreeProcessor(contentFileService); await markdownTreeProcessor.load(); diff --git a/src/git/GitScanner.ts b/src/git/GitScanner.ts index 6dadb272..adbae0e7 100644 --- a/src/git/GitScanner.ts +++ b/src/git/GitScanner.ts @@ -349,38 +349,63 @@ export class GitScanner { } catch (err) { if (err.message.indexOf('Updates were rejected because the remote contains work') > -1 || err.message.indexOf('Updates were rejected because a pushed branch tip is behind its remote') > -1) { - await this.exec(`git fetch origin ${remoteBranch}`, { - env: { - GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : '' + // Stash any local changes before fetching and rebasing + const stashed = await this.stashChanges(); + + try { + await this.exec(`git fetch origin ${remoteBranch}`, { + env: { + GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : '' + } + }); + + try { + await this.exec(`git rebase origin/${remoteBranch}`, { + env: { + GIT_AUTHOR_NAME: committer.name, + GIT_AUTHOR_EMAIL: committer.email, + GIT_COMMITTER_NAME: committer.name, + GIT_COMMITTER_EMAIL: committer.email + } + }); + + // Restore stashed changes if any + if (stashed) { + await this.stashPop(); + + // Check for conflicts after restoring stash + if (await this.hasConflicts()) { + await this.exec('git rebase --abort', { ignoreError: true }); + throw new Error('Stash pop resulted in merge conflicts after rebase. Cannot proceed with push. ' + + 'Please resolve conflicts manually.'); + } + } + } catch (err) { + await this.exec('git rebase --abort', { ignoreError: true }); + if (err.message.indexOf('Resolve all conflicts manually') > -1 || err.message.indexOf('merge conflicts') > -1) { + this.logger.error('Conflict detected during rebase', { filename: __filename }); + throw new Error('Rebase conflicts detected. Please resolve conflicts manually and retry.'); + } + throw err; } - }); - try { - await this.exec(`git rebase origin/${remoteBranch}`, { + await this.exec(`git push origin main:${remoteBranch}`, { env: { - GIT_AUTHOR_NAME: committer.name, - GIT_AUTHOR_EMAIL: committer.email, - GIT_COMMITTER_NAME: committer.name, - GIT_COMMITTER_EMAIL: committer.email + GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : '' } }); + return; } catch (err) { - await this.exec('git rebase --abort', { ignoreError: true }); - if (err.message.indexOf('Resolve all conflicts manually') > -1) { - this.logger.error('Conflict', { filename: __filename }); + // If we stashed something and the operation failed, warn about it + if (stashed) { + this.logger.warn('Push/rebase failed. Stashed changes remain saved. Use `git stash list` to view.', { filename: __filename }); } throw err; } - - await this.exec(`git push origin main:${remoteBranch}`, { - env: { - GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : '' - } - }); - return; } - return; + // For other errors, just throw them + throw err; } } @@ -902,8 +927,9 @@ export class GitScanner { return { ahead, behind }; - // deno-lint-ignore no-empty - } catch (ignore) {} + } catch (err) { + this.logger.warn(`Failed to count ahead/behind commits for branch ${remoteBranch}: ${err.message}`, { filename: __filename }); + } return { ahead: 0, behind: 0 }; } @@ -1013,4 +1039,64 @@ export class GitScanner { this.companionFileResolver = resolver; } + private async getStashCount(): Promise { + const result = await this.exec('git stash list --format=%gd', { skipLogger: !this.debug }); + const lines = result.stdout.trim().split('\n').filter(line => line.length > 0); + return lines.length; + } + + async stashChanges(): Promise { + // Capture the number of existing stash entries before attempting to stash + const beforeCount = await this.getStashCount(); + + try { + await this.exec('git stash push -u -m "WikiGDrive auto-stash before sync"', { skipLogger: !this.debug }); + } catch (err) { + // If there's nothing to stash, git stash may report "No local changes to save" + // in the error output. In that case, report that no stash was created. + if (err.message && err.message.includes('No local changes to save')) { + return false; + } + throw err; + } + + // Re-count stash entries after the push to determine if a new stash was created + const afterCount = await this.getStashCount(); + + return afterCount > beforeCount; + } + + async stashPop(): Promise { + try { + await this.exec('git stash pop', { skipLogger: !this.debug }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // If there's no stash to pop, handle gracefully + // Error message format: "Process exited with status: X\n" + stderr + if (message.includes('No stash entries found')) { + return; + } + // Check if the error is due to merge conflicts based on the error message + if (message.includes('CONFLICT')) { + throw new Error('Stash pop encountered merge conflicts. Please resolve conflicts manually.'); + } + // If the message did not explicitly mention conflicts, fall back to checking for unmerged files + if (await this.hasConflicts()) { + throw new Error('Stash pop encountered merge conflicts. Please resolve conflicts manually.'); + } + throw err; + } + } + + async hasConflicts(): Promise { + try { + const result = await this.exec('git diff --name-only --diff-filter=U', { skipLogger: !this.debug }); + // If any file names are returned, there are unmerged files (conflicts) + return result.stdout.trim().length > 0; + } catch (err) { + this.logger.warn('Failed to check for conflicts: ' + err.message, { filename: __filename }); + return false; + } + } + } diff --git a/test/git/GitTest.ts b/test/git/GitTest.ts index d7f069b9..80873080 100644 --- a/test/git/GitTest.ts +++ b/test/git/GitTest.ts @@ -1,6 +1,6 @@ import fs, {rmSync, unlinkSync} from 'node:fs'; import path from 'node:path'; -import {execSync} from 'node:child_process'; +import {execSync, spawn} from 'node:child_process'; import winston from 'winston'; // eslint-disable-next-line import/no-unresolved @@ -807,3 +807,116 @@ Deno.test('test remove assets not file', async () => { fs.rmSync(localRepoDir, { recursive: true, force: true }); } }); + +Deno.test('test stash and pop', async () => { + const localRepoDir: string = createTmpDir(); + + try { + const scannerLocal = new GitScanner(logger, localRepoDir, COMMITER1.email); + await scannerLocal.initialize(); + + fs.writeFileSync(path.join(localRepoDir, 'file1.md'), 'Initial content'); + await scannerLocal.commit('First commit', ['.gitignore', 'file1.md'], COMMITER1); + + // Create a local change + fs.writeFileSync(path.join(localRepoDir, 'file2.md'), 'New file'); + + { + const changes = await scannerLocal.changes(); + assertStrictEquals(changes.length, 1); + assertStrictEquals(changes[0].path, 'file2.md'); + } + + // Stash changes + const stashed = await scannerLocal.stashChanges(); + assertStrictEquals(stashed, true); + + { + const changes = await scannerLocal.changes(); + assertStrictEquals(changes.length, 0); + } + + // Pop stashed changes + await scannerLocal.stashPop(); + + { + const changes = await scannerLocal.changes(); + assertStrictEquals(changes.length, 1); + assertStrictEquals(changes[0].path, 'file2.md'); + } + + } finally { + fs.rmSync(localRepoDir, { recursive: true, force: true }); + } +}); + +Deno.test('test commit with local behind remote', async () => { + // This test simulates a scenario where WikiGDrive is out of sync with the remote repository: + // - localRepoDir: Represents the WikiGDrive local repository + // - githubRepoDir: Represents the remote GitHub repository (bare repo) + // - secondRepoDir: Represents another contributor who commits directly to GitHub + // The test verifies that the stash/pull/pop workflow correctly syncs local changes + // when the local repository is behind the remote. + + const localRepoDir: string = createTmpDir(); + const githubRepoDir: string = createTmpDir(); + const secondRepoDir: string = createTmpDir(); + + try { + execSync(`git init -b main --bare ${githubRepoDir}`); + + // Setup first repo (WikiGDrive local repository) + const scannerLocal = new GitScanner(logger, localRepoDir, COMMITER1.email); + await scannerLocal.initialize(); + + fs.writeFileSync(path.join(localRepoDir, 'file1.md'), 'Initial content'); + await scannerLocal.commit('First commit', ['.gitignore', 'file1.md'], COMMITER1); + + await scannerLocal.setRemoteUrl(githubRepoDir); + await scannerLocal.pushBranch('main'); + + // Setup second repo (simulates another contributor pushing to GitHub) + const scannerSecond = new GitScanner(logger, secondRepoDir, COMMITER2.email); + await scannerSecond.initialize(); + fs.unlinkSync(secondRepoDir + '/.gitignore'); + await scannerSecond.setRemoteUrl(githubRepoDir); + await scannerSecond.pullBranch('main'); + + fs.writeFileSync(path.join(secondRepoDir, 'file2.md'), 'Second repo change'); + await scannerSecond.commit('Second commit', ['file2.md'], COMMITER2); + await scannerSecond.pushBranch('main'); + + // WikiGDrive repository now has local changes but is behind remote + fs.writeFileSync(path.join(localRepoDir, 'file3.md'), 'Local change'); + + // Fetch to make remote refs available + await scannerLocal.fetch(); + + const { ahead, behind } = await scannerLocal.countAheadBehind('main'); + assertStrictEquals(ahead, 0); + assertStrictEquals(behind, 1); + + // Test stash, pull, and pop workflow + const stashed = await scannerLocal.stashChanges(); + assertStrictEquals(stashed, true); + await scannerLocal.pullBranch('main'); + await scannerLocal.stashPop(); + + // Verify we now have both files + assertStrictEquals(fs.existsSync(path.join(localRepoDir, 'file2.md')), true); + assertStrictEquals(fs.existsSync(path.join(localRepoDir, 'file3.md')), true); + + // Verify we're now up to date + const { ahead: newAhead, behind: newBehind } = await scannerLocal.countAheadBehind('main'); + assertStrictEquals(newAhead, 0); + assertStrictEquals(newBehind, 0); + + // Now we can commit successfully + await scannerLocal.commit('Third commit', ['file3.md'], COMMITER1); + + } finally { + fs.rmSync(localRepoDir, { recursive: true, force: true }); + fs.rmSync(githubRepoDir, { recursive: true, force: true }); + fs.rmSync(secondRepoDir, { recursive: true, force: true }); + } +});