SPB Git

Repository Public

git.test.ts 13.6 KB 262 lines typescript

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
Files main
Menu

Theme