Files
ycode/scripts/ops/git.mjs
T

36 lines
4.0 KiB
JavaScript

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}};
}