fix: preserve remote workspace changes across sessions

This commit is contained in:
2026-10-14 14:06:44 +09:00
parent debb38a29b
commit c9cceb4c01
4 changed files with 135 additions and 45 deletions
+13 -42
View File
@@ -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(
@@ -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<string, unknown>) {
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)
})