import fs from 'node:fs/promises'; import path from 'node:path'; import {run,OpsError,requireSuccess} from './core.mjs'; export const EXPECTED_REMOTE='https://gitea.iiiii.run/admin/ycode.git'; export async function git(ctx,args,options={}) {const result=await run('git',['-C',ctx.root,...args],{cwd:ctx.root,timeoutMs:30000,env:{...process.env,GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GCM_INTERACTIVE:'Never',GIT_ASKPASS:'',SSH_ASKPASS:''},...options});if(result.timedOut)ctx.keepLock=true;return result;} export async function resolveContext(options) { const requested=await fs.realpath(options.root || process.cwd()).catch(()=>{throw new OpsError('ROOT_NOT_FOUND','Target directory does not exist.');}); const root=await fs.realpath(requireSuccess(await git({root:requested},['rev-parse','--show-toplevel']),'NOT_REPOSITORY','Target is not a Git working tree.')); if(options.root && root!==requested)throw new OpsError('ROOT_NOT_TOPLEVEL','Explicit --root must be the repository top-level.',{requested,root}); let manifest;try{manifest=JSON.parse(await fs.readFile(path.join(root,'package.json'),'utf8'));}catch{throw new OpsError('WRONG_REPOSITORY','Missing YCode package.json.');} if(manifest.name!=='ycode')throw new OpsError('WRONG_REPOSITORY','Target package is not ycode.'); const gitDir=requireSuccess(await git({root},['rev-parse','--absolute-git-dir']),'GIT_FAILED','Cannot resolve private Git directory.'); return {...options,root,manifest,stateDir:path.join(gitDir,'ycode-ops'),logDir:path.join(gitDir,'ycode-ops','logs')}; } export async function gitState(ctx) { const sha=requireSuccess(await git(ctx,['rev-parse','HEAD']),'GIT_FAILED','Cannot read HEAD.'); const status=requireSuccess(await git(ctx,['status','--porcelain=v1','--untracked-files=normal']),'GIT_FAILED','Cannot read worktree status.'); const branch=(await git(ctx,['symbolic-ref','--quiet','--short','HEAD'])).stdout.trim() || null; return {sha,dirty:!!status,branch,changes:status.split('\n').filter(Boolean).slice(0,30),changesTruncated:status.split('\n').length>30}; } export async function pull(ctx) { const before=await gitState(ctx); if(before.dirty)throw new OpsError('DIRTY_WORKTREE','Local changes preserved; latest requires a clean checkout.',{git:before}); if(before.branch!=='master')throw new OpsError('WRONG_BRANCH','Pull requires checked-out master; no automatic branch switching.',{git:before}); const remote=requireSuccess(await git(ctx,['remote','get-url','--all','gitea']),'REMOTE_MISSING','Expected gitea remote is missing.'); if(remote!==EXPECTED_REMOTE)throw new OpsError('REMOTE_MISMATCH','gitea URL does not match the approved repository.'); requireSuccess(await git(ctx,['fetch','--no-tags','gitea','refs/heads/master:refs/remotes/gitea/master'],{timeoutMs:ctx.timeoutMs,logDir:ctx.logDir}),'FETCH_FAILED','Fetch failed; cached refs are not latest evidence.'); const target=requireSuccess(await git(ctx,['rev-parse','refs/remotes/gitea/master']),'GIT_FAILED','Cannot resolve fetched master.'); // An ahead-only checkout also fails: latest means exact equality with fetched master. if((await git(ctx,['merge-base','--is-ancestor',before.sha,target])).exitCode!==0)throw new OpsError('NOT_FAST_FORWARD','Local history is ahead or divergent; no reset, stash or merge commit permitted.',{before:before.sha,target}); const now=await gitState(ctx);if(now.dirty||now.sha!==before.sha)throw new OpsError('WORKTREE_CHANGED','Workspace changed during fetch; synchronization stopped.'); requireSuccess(await git(ctx,['-c','core.hooksPath='+path.join(ctx.stateDir,'disabled-hooks'),'merge','--ff-only','--quiet',target],{logDir:ctx.logDir}),'FAST_FORWARD_FAILED','Fast-forward failed; local state was not forcibly overwritten.'); const after=await gitState(ctx);if(after.sha!==target||after.dirty)throw new OpsError('SYNC_UNVERIFIED','Post-sync state does not match fetched clean master.',{git:after,target},'FAIL'); return {status:'PASS',code:'SYNCED',summary:'Clean master matches this fetch of gitea/master.',git:after,evidence:{before:before.sha,fetchedSha:target,remote:EXPECTED_REMOTE}}; }