diff --git a/src/main/index.ts b/src/main/index.ts index e7536cd..2b71973 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4231,21 +4231,20 @@ async function installRemoteWorkspaceModule( `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, `PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`, 'mkdir -p "$ROOT"', - 'if [ ! -d "$TARGET/.git" ]; then git clone "$TEMPLATE_REPO" "$TARGET"; fi', - 'git -C "$TARGET" remote set-url origin "$TEMPLATE_REPO"', - 'git -C "$TARGET" fetch origin --prune', - 'CURRENT_STUDENT="$(git -C "$TARGET" remote get-url student 2>/dev/null || true)"', - 'if [ "$CURRENT_STUDENT" != "$STUDENT_REPO" ]; then', - ' git -C "$TARGET" checkout -B main origin/main', - ' git -C "$TARGET" reset --hard origin/main', - ' git -C "$TARGET" clean -fdx', - ' git -C "$TARGET" remote remove student >/dev/null 2>&1 || true', + // An existing workspace owns its source, commits and build artifacts. Install + // retries (including changed repository credentials) must not overwrite it. + 'if [ -e "$TARGET/.git" ]; then', + ' printf "OK\\t%s\\n" "$TARGET"', + ' exit 0', + 'fi', + 'git clone "$TEMPLATE_REPO" "$TARGET"', + 'if [ -n "$STUDENT_REPO" ]; then', ' git -C "$TARGET" remote add student "$STUDENT_REPO"', - ' git -C "$TARGET" fetch student --prune || true', + ' git -C "$TARGET" fetch student --prune', ' if git -C "$TARGET" show-ref --verify --quiet refs/remotes/student/main; then', - ' git -C "$TARGET" checkout -B student-work student/main', + ' git -C "$TARGET" checkout -b student-work student/main', ' else', - ' git -C "$TARGET" checkout -B student-work origin/main', + ' git -C "$TARGET" checkout -b student-work origin/main', ' git -C "$TARGET" push student student-work:main', ' fi', 'fi', @@ -5732,36 +5731,8 @@ async function resetRemoteWorkspaceSession( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>, ): Promise<{ ok: boolean; message: string }> { - try { - const target = normalizeRemoteTarget(targetInput) - await stopRemoteWorkspaceModules(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' })) - const commands = [ - 'set -e', - `ROOT=${quoteShellSingle(target.workspaceRoot)}`, - ...expandRemotePathVar('ROOT'), - ] - for (const module of modules) { - const folderName = module.folderName?.trim() - const templateRepositoryUrl = module.templateRepositoryUrl?.trim() - if (!folderName || !templateRepositoryUrl) continue - const templateRepositoryWithCredential = withGitCredential(remoteRepositoryUrl(templateRepositoryUrl)) - commands.push( - `MODULE="$ROOT/${folderName}"`, - 'if [ -d "$MODULE/.git" ]; then', - ' git -C "$MODULE" remote remove student >/dev/null 2>&1 || true', - ` git -C "$MODULE" remote set-url origin ${quoteShellSingle(templateRepositoryWithCredential)}`, - ' git -C "$MODULE" fetch origin --prune', - ' git -C "$MODULE" checkout -B main origin/main', - ' git -C "$MODULE" reset --hard origin/main', - ' git -C "$MODULE" clean -fdx', - 'fi', - ) - } - await runSsh(target, commands.join('\n'), 180000) - return { ok: true, message: '템플릿 기준으로 초기화했습니다.' } - } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) } - } + // Keep the legacy IPC contract without resetting student files or Git history. + return stopRemoteWorkspaceModules(targetInput, modules) } async function restartWorkspaceModule( diff --git a/src/main/remoteWorkspacePreservation.test.ts b/src/main/remoteWorkspacePreservation.test.ts new file mode 100644 index 0000000..2f3e220 --- /dev/null +++ b/src/main/remoteWorkspacePreservation.test.ts @@ -0,0 +1,116 @@ +import { strict as assert } from 'node:assert' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { test } from 'node:test' +import ts from 'typescript' + +const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true) +function loadFunction(name: string, dependencies: Record) { + const declaration = source.statements.find((item) => ts.isFunctionDeclaration(item) && item.name?.text === name) + assert.ok(declaration) + const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + return new Function(...Object.keys(dependencies), `${code}; return ${name};`)(...Object.values(dependencies)) +} + +function command(executable: string, args: string[], cwd: string) { + const result = spawnSync(executable, args, { + cwd, encoding: 'utf8', timeout: 15000, + env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' }, + }) + assert.equal(result.status, 0, result.stderr || String(result.error)) + return result.stdout.trim() +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'lms-preserve-')) + const template = join(root, 'template') + mkdirSync(template) + command('git', ['init', '-b', 'main'], template) + writeFileSync(join(template, 'node.py'), 'template source\n') + writeFileSync(join(template, '.gitignore'), 'build/\ninstall/\nlog/\n') + command('git', ['add', '.'], template) + command('git', ['commit', '-m', 'template'], template) + const install = loadFunction('installRemoteWorkspaceModule', { + normalizeRemoteTarget: (target: unknown) => target, + remoteRepositoryUrl: (url: string) => url, + withGitCredential: (url: string) => url, + remotePathJoin: join, + safeArtifactFileName: () => 'unused.tar', + quoteShellSingle: (value: string) => "'" + value.replaceAll("'", "'\\''") + "'", + encodeBase64: (value: string) => Buffer.from(value).toString('base64'), + expandRemotePathVar: () => [], + runSsh: async (_target: unknown, script: string) => command('bash', ['-c', script], root), + parseModuleFolderName: () => ({ name: 'Test', category: 'Control' }), + }) + const target = { workspaceRoot: join(root, 'workspace') } + return { root, template, target, install, modulePath: join(target.workspaceRoot, 'module') } +} + +test('reinstall preserves saved source, local commits, untracked files and build outputs despite changed repository URLs', async () => { + const f = fixture() + try { + await f.install(f.target, 'Test', 'module', f.template) + writeFileSync(join(f.modulePath, 'node.py'), 'student commit\n') + command('git', ['add', 'node.py'], f.modulePath) + command('git', ['commit', '-m', 'student work'], f.modulePath) + const head = command('git', ['rev-parse', 'HEAD'], f.modulePath) + writeFileSync(join(f.modulePath, 'node.py'), 'saved but not committed\n') + writeFileSync(join(f.modulePath, 'new.py'), 'new student file\n') + for (const dir of ['build', 'install', 'log']) { + mkdirSync(join(f.modulePath, dir)) + writeFileSync(join(f.modulePath, dir, 'keep'), dir) + } + const status = command('git', ['status', '--porcelain'], f.modulePath) + await f.install(f.target, 'Test', 'module', '/missing/new-template', '/missing/new-student', { + prebuiltArtifacts: [{ url: 'https://invalid.example/overwrite.tar', required: true }], + }) + assert.equal(command('git', ['rev-parse', 'HEAD'], f.modulePath), head) + assert.equal(command('git', ['status', '--porcelain'], f.modulePath), status) + assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'saved but not committed\n') + assert.equal(readFileSync(join(f.modulePath, 'new.py'), 'utf8'), 'new student file\n') + for (const dir of ['build', 'install', 'log']) assert.equal(readFileSync(join(f.modulePath, dir, 'keep'), 'utf8'), dir) + } finally { rmSync(f.root, { recursive: true, force: true }) } +}) + +test('first install restores the student repository instead of replacing it with template source', async () => { + const f = fixture() + try { + const student = join(f.root, 'student') + command('git', ['clone', f.template, student], f.root) + writeFileSync(join(student, 'node.py'), 'submitted student work\n') + command('git', ['add', '.'], student) + command('git', ['commit', '-m', 'submission'], student) + await f.install(f.target, 'Test', 'module', f.template, student) + assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'submitted student work\n') + assert.equal(command('git', ['branch', '--show-current'], f.modulePath), 'student-work') + } finally { rmSync(f.root, { recursive: true, force: true }) } +}) + +test('first install initializes an empty student repository', async () => { + const f = fixture() + try { + const student = join(f.root, 'student.git') + command('git', ['init', '--bare', student], f.root) + await f.install(f.target, 'Test', 'module', f.template, student) + assert.equal(command('git', ['--git-dir', student, 'show', 'main:node.py'], f.root), 'template source') + } finally { rmSync(f.root, { recursive: true, force: true }) } +}) + +test('legacy session reset only stops modules and returns stop failures', async () => { + const target = { workspaceRoot: '/student/workspace' } + const modules = [{ moduleName: 'Test', folderName: 'module', templateRepositoryUrl: '/template' }] + const result = { ok: false, message: 'stop failed' } + let calls = 0 + const reset = loadFunction('resetRemoteWorkspaceSession', { + stopRemoteWorkspaceModules: async (actualTarget: unknown, actualModules: unknown) => { + calls++ + assert.equal(actualTarget, target) + assert.equal(actualModules, modules) + return result + }, + }) + assert.equal(await reset(target, modules), result) + assert.equal(calls, 1) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d0ec402..d94d22e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4493,9 +4493,9 @@ export default function App(): React.JSX.Element { const modules = (workspaceWeek.modules ?? []) .slice() .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) - .map((module) => ({ moduleName: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl })) + .map((module) => ({ moduleName: module.name, folderName: module.folderName })) if (!modules.length) return - await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) + await window.remoteWorkspace.modules.stopMany(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' })) } const getFreshAccessToken = useCallback(async (): Promise => { const currentSession = readStoredAuthSession() ?? authSessionRef.current diff --git a/src/renderer/src/features/workspace/useRemoteWorkspaceRuntime.ts b/src/renderer/src/features/workspace/useRemoteWorkspaceRuntime.ts index 86b6e6d..73dc6f4 100644 --- a/src/renderer/src/features/workspace/useRemoteWorkspaceRuntime.ts +++ b/src/renderer/src/features/workspace/useRemoteWorkspaceRuntime.ts @@ -115,7 +115,10 @@ export function useRemoteWorkspaceRuntime({ .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) .map(({ moduleName, folderName, templateRepositoryUrl }) => ({ moduleName, folderName, templateRepositoryUrl })) if (!modules.length) return - await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) + await window.remoteWorkspace.modules.stopMany( + getRemoteWorkspaceTarget(), + modules.map(({ moduleName, folderName }) => ({ moduleName, folderName })), + ).catch(() => ({ ok: false, message: '모듈 중지 실패' })) return } await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' }))