Repository Public
1import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, symlinkSync } from 'node:fs';2import path from 'node:path';3import { afterEach, beforeEach, describe, expect, it } from 'vitest';4import { TOOLS } from './index.js';5import { git, makeFixture, type Fixture } from './testutil.js';67let fx: Fixture;8beforeEach(() => {9 fx = makeFixture({ git: true });10});11afterEach(() => fx.cleanup());12const T = (name: string) => TOOLS[name]!;13const w = (rel: string, content: string) => {14 mkdirSync(path.dirname(path.join(fx.ws, rel)), { recursive: true });15 writeFileSync(path.join(fx.ws, rel), content);16};17const headCount = () => Number(git(fx.ws, 'rev-list', '--count', 'HEAD').trim());1819function addBareRemote(): string {20 const bare = path.join(fx.root, 'remote.git');21 git(fx.root, 'init', '-q', '--bare', '-b', 'main', bare);22 git(fx.ws, 'remote', 'add', 'origin', bare);23 return bare;24}2526describe('git_status / git_diff', () => {27 it('status shows branch and changes; clean tree is explicit', async () => {28 const clean = await fx.call(T('git_status'), {});29 expect(clean.ok).toBe(true);30 expect(clean.output).toBe('## main\n(working tree clean)');31 w('new.txt', 'x\n');32 w('README.md', '# changed\n');33 const res = await fx.call(T('git_status'), {});34 expect(res.output).toBe('## main\n M README.md\n?? new.txt');35 });36 it('diff: unstaged, staged, ref, paths', async () => {37 w('README.md', '# changed\n');38 const un = await fx.call(T('git_diff'), { staged: null, ref: null, paths: null });39 expect(un.ok).toBe(true);40 expect(un.output).toContain('-# test\n+# changed');41 expect(un.diff).toContain('+# changed');42 expect((await fx.call(T('git_diff'), { staged: true })).output).toBe('(no differences)');43 git(fx.ws, 'add', '-A');44 expect((await fx.call(T('git_diff'), { staged: true })).output).toContain('+# changed');45 expect((await fx.call(T('git_diff'), { ref: 'HEAD', paths: ['README.md'] })).output).toContain('+# changed');46 expect((await fx.call(T('git_diff'), { ref: 'HEAD', paths: ['nothing-here'] })).output).toBe('(no differences)');47 });48 it('diff refuses option-like refs and escaping paths', async () => {49 for (const ref of ['--output=/tmp/aos-pwn', '-O/tmp/x', '--ext-diff', 'a b', '$(id)']) {50 const res = await fx.call(T('git_diff'), { ref });51 expect(res.ok, ref).toBe(false);52 expect(res.output, ref).toMatch(/Invalid ref/);53 }54 expect((await fx.call(T('git_diff'), { paths: ['../x'] })).ok).toBe(false);55 expect((await fx.call(T('git_diff'), { paths: ['/etc/passwd'] })).output).toMatch(/outside/);56 expect(existsSync('/tmp/aos-pwn')).toBe(false);57 expect((await fx.call(T('git_diff'), { ref: 'no-such-rev' })).ok).toBe(false);58 });59 it('every git tool fails clearly outside a repository and does not discover a parent repo', async () => {60 const plain = makeFixture();61 try {62 git(plain.root, 'init', '-q', '.'); // a repo ABOVE the workspace must not be used63 for (const [name, input] of [['git_status', {}], ['git_diff', {}], ['git_commit', { message: 'm', paths: null }], ['git_push', {}]] as const) {64 const res = await plain.call(T(name), input);65 expect(res.ok, name).toBe(false);66 expect(res.output, name).toMatch(/not a git repository/);67 }68 expect(await T('git_push').reconcile!({ remote: null, branch: null }, plain.ctx)).toEqual({ unknown: true });69 } finally {70 await plain.cleanup();71 }72 });73});7475describe('git_commit', () => {76 it('stages everything, commits with mission + step trailers', async () => {77 w('a.txt', 'a\n');78 w('sub/b.txt', 'b\n');79 const res = await fx.call(T('git_commit'), { message: 'feat: add files\n\nBody line.', paths: null });80 expect(res.ok).toBe(true);81 expect(res.output).toMatch(/^Committed\.\n[0-9a-f]{7,} feat: add files/);82 expect(headCount()).toBe(2);83 const body = git(fx.ws, 'log', '-1', '--format=%B');84 expect(body).toBe('feat: add files\n\nBody line.\n\nAOS-Mission: mission-0001\nAOS-Step: idem-1\n\n');85 expect(git(fx.ws, 'log', '-1', '--format=%(trailers:key=AOS-Mission,valueonly)').trim()).toBe('mission-0001');86 expect(git(fx.ws, 'status', '--porcelain')).toBe('');87 });88 it('stages only the given paths (including a deletion)', async () => {89 w('a.txt', 'a\n');90 w('b.txt', 'b\n');91 await fx.call(T('git_commit'), { message: 'both', paths: null });92 w('a.txt', 'a2\n');93 w('b.txt', 'b2\n');94 const res = await fx.call(T('git_commit'), { message: 'only a', paths: ['a.txt'] });95 expect(res.ok).toBe(true);96 expect(git(fx.ws, 'status', '--porcelain')).toBe(' M b.txt\n');97 git(fx.ws, 'checkout', '--', 'b.txt');98 git(fx.ws, 'rm', '-q', '--cached', 'a.txt');99 git(fx.ws, 'reset', '-q');100 (await import('node:fs')).rmSync(path.join(fx.ws, 'a.txt'));101 expect((await fx.call(T('git_commit'), { message: 'delete a', paths: ['a.txt'] })).ok).toBe(true);102 expect(git(fx.ws, 'ls-files')).toBe('README.md\nb.txt\n');103 });104 it('nothing to commit ⇒ ok no-op', async () => {105 const res = await fx.call(T('git_commit'), { message: 'empty', paths: null });106 expect(res.ok).toBe(true);107 expect(res.output).toMatch(/Nothing to commit.*no-op/);108 expect(headCount()).toBe(1);109 });110 it('same idem key already at HEAD ⇒ ok no-op, even with new changes pending', async () => {111 w('a.txt', 'a\n');112 const input = { message: 'once', paths: null };113 const first = await T('git_commit').run(input, { ...fx.ctx, idemKey: 'run1:7' });114 expect(first.ok).toBe(true);115 w('later.txt', 'belongs to a later step\n');116 const again = await T('git_commit').run(input, { ...fx.ctx, idemKey: 'run1:7' });117 expect(again.ok).toBe(true);118 expect(again.output).toMatch(/Already committed by this step/);119 expect(headCount()).toBe(2);120 expect(git(fx.ws, 'status', '--porcelain')).toBe('?? later.txt\n');121 const next = await T('git_commit').run({ message: 'later', paths: null }, { ...fx.ctx, idemKey: 'run1:8' });122 expect(next.ok).toBe(true);123 expect(headCount()).toBe(3);124 });125 it('does NOT execute repository hooks, fsmonitor, or an agent-planted editor/gpg program', async () => {126 const marker = path.join(fx.root, 'hook-ran');127 const hooks = path.join(fx.ws, '.git', 'hooks');128 mkdirSync(hooks, { recursive: true });129 for (const h of ['pre-commit', 'commit-msg', 'prepare-commit-msg', 'post-commit', 'pre-push', 'post-index-change']) {130 writeFileSync(path.join(hooks, h), `#!/bin/sh\necho ${h} >> '${marker}'\nexit 1\n`);131 chmodSync(path.join(hooks, h), 0o755);132 }133 const evil = path.join(fx.root, 'evil.sh');134 writeFileSync(evil, `#!/bin/sh\necho evil >> '${marker}'\nexit 0\n`);135 chmodSync(evil, 0o755);136 appendFileSync(path.join(fx.ws, '.git', 'config'), `[core]\n\tfsmonitor = ${evil}\n\teditor = ${evil}\n\tpager = ${evil}\n[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = ${evil}\n`);137 w('a.txt', 'a\n');138 expect((await fx.call(T('git_status'), {})).ok).toBe(true);139 expect((await fx.call(T('git_diff'), {})).ok).toBe(true);140 const res = await fx.call(T('git_commit'), { message: 'no hooks', paths: null });141 expect(res.ok, res.output).toBe(true);142 expect(headCount()).toBe(2);143 addBareRemote();144 const push = await fx.call(T('git_push'), { remote: null, branch: null });145 expect(push.ok, push.output).toBe(true);146 expect(existsSync(marker)).toBe(false);147 });148 it('does not run filter/textconv drivers or recurse into a nested repository with a hostile config', async () => {149 const marker = path.join(fx.root, 'nested-ran');150 const evil = path.join(fx.root, 'evil.sh');151 writeFileSync(evil, `#!/bin/sh\necho evil >> '${marker}'\ncat\n`);152 chmodSync(evil, 0o755);153 const nested = path.join(fx.ws, 'vendor', 'lib');154 mkdirSync(nested, { recursive: true });155 git(nested, 'init', '-q', '.');156 writeFileSync(path.join(nested, 'f.txt'), 'x\n');157 git(nested, '-c', 'user.name=t', '-c', 'user.email=t@e.invalid', 'add', '-A');158 git(nested, '-c', 'user.name=t', '-c', 'user.email=t@e.invalid', 'commit', '-q', '-m', 'nested');159 appendFileSync(path.join(nested, '.git', 'config'), `[core]\n\tfsmonitor = ${evil}\n`);160 writeFileSync(path.join(fx.ws, '.gitattributes'), '*.txt filter=evil diff=evil\n');161 w('a.txt', 'a\n');162 expect((await fx.call(T('git_status'), {})).ok).toBe(true);163 expect((await fx.call(T('git_commit'), { message: 'with nested', paths: null })).ok).toBe(true);164 writeFileSync(path.join(nested, 'f.txt'), 'changed\n');165 expect((await fx.call(T('git_status'), {})).ok).toBe(true);166 expect((await fx.call(T('git_diff'), {})).ok).toBe(true);167 expect(existsSync(marker)).toBe(false);168 });169 it('ignores a git/ssh binary planted in the workspace even if the worker PATH includes it', async () => {170 const marker = path.join(fx.root, 'planted-ran');171 mkdirSync(path.join(fx.ws, 'bin'));172 writeFileSync(path.join(fx.ws, 'bin', 'git'), `#!/bin/sh\necho planted >> '${marker}'\nexit 0\n`);173 chmodSync(path.join(fx.ws, 'bin', 'git'), 0o755);174 const saved = process.env.PATH;175 process.env.PATH = `${path.join(fx.ws, 'bin')}:.:bin:${saved}`;176 try {177 const res = await fx.call(T('git_status'), {});178 expect(res.ok).toBe(true);179 expect(res.output).toContain('## main');180 } finally {181 process.env.PATH = saved;182 }183 expect(existsSync(marker)).toBe(false);184 });185 it('rejects escaping paths', async () => {186 symlinkSync(fx.root, path.join(fx.ws, 'up'));187 expect((await fx.call(T('git_commit'), { message: 'm', paths: ['../x'] })).ok).toBe(false);188 expect((await fx.call(T('git_commit'), { message: 'm', paths: ['up/data'] })).output).toMatch(/symbolic link/);189 const dash = await fx.call(T('git_commit'), { message: 'm', paths: ['--force'] }); // a literal pathspec after "--", never an option190 expect(dash.ok).toBe(false);191 expect(dash.output).toMatch(/pathspec '--force' did not match/);192 expect(headCount()).toBe(1);193 });194 it('commits even when no identity is configured', async () => {195 git(fx.ws, 'config', '--unset', 'user.name');196 git(fx.ws, 'config', '--unset', 'user.email');197 w('a.txt', 'a\n');198 const res = await fx.call(T('git_commit'), { message: 'anon', paths: null });199 expect(res.ok, res.output).toBe(true);200 expect(git(fx.ws, 'log', '-1', '--format=%an').trim().length).toBeGreaterThan(0);201 });202});203204describe('git_push + reconcile', () => {205 it('pushes to a local bare remote; the reconcile probe reports done / not done / unknown', async () => {206 const bare = addBareRemote();207 const input = { remote: null, branch: null };208 expect(await T('git_push').reconcile!(input, fx.ctx)).toEqual({ done: false }); // nothing pushed yet209 const res = await fx.call(T('git_push'), input);210 expect(res.ok, res.output).toBe(true);211 expect(res.output).toMatch(/^Pushed main to origin at [0-9a-f]{12}\./);212 const local = git(fx.ws, 'rev-parse', 'refs/heads/main').trim();213 expect(git(bare, 'rev-parse', 'refs/heads/main').trim()).toBe(local);214 const rec = await T('git_push').reconcile!(input, fx.ctx);215 expect(rec).toMatchObject({ done: true });216 expect('output' in rec && rec.output).toContain(local.slice(0, 12));217218 // a new local commit ⇒ the remote is behind ⇒ not done219 w('a.txt', 'a\n');220 await fx.call(T('git_commit'), { message: 'second', paths: null });221 expect(await T('git_push').reconcile!(input, fx.ctx)).toEqual({ done: false });222 expect((await fx.call(T('git_push'), { remote: 'origin', branch: 'main' })).ok).toBe(true);223 expect(await T('git_push').reconcile!({ remote: 'origin', branch: 'main' }, fx.ctx)).toMatchObject({ done: true });224225 // unreachable remote ⇒ unknown (never "not done": the kernel must ask the owner)226 git(fx.ws, 'remote', 'add', 'dead', path.join(fx.root, 'no-such-remote.git'));227 expect(await T('git_push').reconcile!({ remote: 'dead', branch: 'main' }, fx.ctx)).toEqual({ unknown: true });228 expect((await fx.call(T('git_push'), { remote: 'dead', branch: 'main' })).ok).toBe(false);229 });230 it('refuses URLs, unknown remotes, option-like names, unknown branches, and non-fast-forward', async () => {231 const bare = addBareRemote();232 for (const [input, re] of [233 [{ remote: bare, branch: null }, /Invalid remote name/],234 [{ remote: 'ext::sh -c id', branch: null }, /Invalid remote name/],235 [{ remote: '--receive-pack=evil', branch: null }, /Invalid remote name/],236 [{ remote: 'upstream', branch: null }, /not configured/],237 [{ remote: null, branch: 'nope' }, /does not exist/],238 [{ remote: null, branch: '--force' }, /Invalid branch/],239 [{ remote: null, branch: 'a..b' }, /Invalid branch/],240 ] as const) {241 const res = await fx.call(T('git_push'), input);242 expect(res.ok, JSON.stringify(input)).toBe(false);243 expect(res.output, JSON.stringify(input)).toMatch(re);244 }245 await fx.call(T('git_push'), {});246 // someone else pushes; we diverge ⇒ rejected, never forced247 const other = path.join(fx.root, 'other');248 git(fx.root, 'clone', '-q', bare, other);249 writeFileSync(path.join(other, 'theirs.txt'), 't\n');250 git(other, 'add', '-A');251 git(other, '-c', 'user.name=o', '-c', 'user.email=o@e.invalid', 'commit', '-q', '-m', 'theirs');252 git(other, 'push', '-q', 'origin', 'main');253 w('mine.txt', 'm\n');254 await fx.call(T('git_commit'), { message: 'mine', paths: null });255 const rejected = await fx.call(T('git_push'), {});256 expect(rejected.ok).toBe(false);257 expect(rejected.output).toMatch(/rejected|fetch first|non-fast-forward/);258 expect(readFileSync(path.join(other, 'theirs.txt'), 'utf8')).toBe('t\n');259 expect(git(bare, 'log', '-1', '--format=%s', 'main').trim()).toBe('theirs');260 });261});262