agentic-workflow-manager 9.19.0 → 9.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/run.js +1 -0
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const child_process_1 = __importDefault(require("child_process"));
|
|
9
|
+
const git_env_1 = require("../../../src/core/changes/git-env");
|
|
10
|
+
const git_1 = require("../../../src/core/changes/git");
|
|
11
|
+
const receipt_1 = require("../../../src/core/changes/receipt");
|
|
12
|
+
const change_1 = require("../../../src/commands/change");
|
|
13
|
+
const worktree_1 = require("../../../src/core/changes/worktree");
|
|
14
|
+
const helpers_1 = require("./helpers");
|
|
15
|
+
describe('gitSpawnOptions', () => {
|
|
16
|
+
test('has the cwd, utf8, the shared timeout and a scrubbed environment (stdio is spelled out by execGit)', () => {
|
|
17
|
+
(0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1', GIT_AUTHOR_NAME: 'a' }, () => {
|
|
18
|
+
const opts = (0, git_env_1.gitSpawnOptions)('/some/dir');
|
|
19
|
+
expect(opts).toMatchObject({ cwd: '/some/dir', encoding: 'utf8', timeout: git_env_1.GIT_TIMEOUT_MS });
|
|
20
|
+
expect(opts.env.GIT_LITERAL_PATHSPECS).toBeUndefined();
|
|
21
|
+
expect(opts.env.GIT_AUTHOR_NAME).toBe('a');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
test('every git child runs with lazy fetching off', () => {
|
|
25
|
+
expect((0, git_env_1.gitSpawnOptions)('/some/dir').env.GIT_NO_LAZY_FETCH).toBe('1');
|
|
26
|
+
});
|
|
27
|
+
test('takes the output buffer from an explicit parameter', () => {
|
|
28
|
+
const opts = (0, git_env_1.gitSpawnOptions)('/d', { stderr: 'ignore', maxBuffer: 64 * 1024 * 1024 });
|
|
29
|
+
expect(opts.maxBuffer).toBe(64 * 1024 * 1024);
|
|
30
|
+
});
|
|
31
|
+
test('leaves maxBuffer to the Node default when none is requested', () => {
|
|
32
|
+
expect('maxBuffer' in (0, git_env_1.gitSpawnOptions)('/d')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('gitChildEnv', () => {
|
|
36
|
+
test('removes the pathspec variables whatever their case, and keeps everything else', () => {
|
|
37
|
+
(0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1', git_literal_pathspecs: '1', Git_Icase_Pathspecs: '1', GIT_GLOB_PATHSPECS: '1', git_noglob_pathspecs: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'a', AWM_KEEP_ME: 'k' }, () => {
|
|
38
|
+
const env = (0, git_env_1.gitChildEnv)();
|
|
39
|
+
expect(Object.keys(env).filter((k) => k.toUpperCase().endsWith('_PATHSPECS'))).toEqual([]);
|
|
40
|
+
expect(env.GIT_CONFIG_NOSYSTEM).toBe('1');
|
|
41
|
+
expect(env.GIT_AUTHOR_NAME).toBe('a');
|
|
42
|
+
expect(env.AWM_KEEP_ME).toBe('k');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
test('turns lazy fetching off, whatever the caller has set, so that reading never fetches from a promisor remote', () => {
|
|
46
|
+
for (const value of [undefined, '0', 'false', '']) {
|
|
47
|
+
(0, helpers_1.withEnv)(value === undefined ? {} : { GIT_NO_LAZY_FETCH: value }, () => expect((0, git_env_1.gitChildEnv)().GIT_NO_LAZY_FETCH).toBe('1'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test('turns lazy fetching off under any spelling of the name (environment names are case-insensitive on Windows)', () => {
|
|
51
|
+
(0, helpers_1.withEnv)({ git_no_lazy_fetch: '0' }, () => {
|
|
52
|
+
const env = (0, git_env_1.gitChildEnv)();
|
|
53
|
+
expect(Object.keys(env).filter((k) => k.toUpperCase() === 'GIT_NO_LAZY_FETCH')).toEqual(['GIT_NO_LAZY_FETCH']);
|
|
54
|
+
expect(env.GIT_NO_LAZY_FETCH).toBe('1');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
test.each(['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX'])('removes the repository-location variable %s under any spelling, and keeps the rest of the environment', (name) => {
|
|
58
|
+
(0, helpers_1.withEnv)({ [name]: '/hostile', [name.toLowerCase()]: '/hostile', GIT_AUTHOR_NAME: 'a' }, () => {
|
|
59
|
+
const env = (0, git_env_1.gitChildEnv)();
|
|
60
|
+
expect(Object.keys(env).filter((k) => k.toUpperCase() === name)).toEqual([]);
|
|
61
|
+
expect(env.GIT_AUTHOR_NAME).toBe('a');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
test('does not modify process.env', () => {
|
|
65
|
+
(0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1' }, () => {
|
|
66
|
+
(0, git_env_1.gitChildEnv)();
|
|
67
|
+
expect(process.env.GIT_LITERAL_PATHSPECS).toBe('1');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('the options every git child is really spawned with (stdio is explicit at the call)', () => {
|
|
72
|
+
const optionsOfCalls = (fn) => {
|
|
73
|
+
const real = child_process_1.default.execFileSync;
|
|
74
|
+
const seen = [];
|
|
75
|
+
const spy = jest.spyOn(child_process_1.default, 'execFileSync').mockImplementation(((cmd, args, opts) => {
|
|
76
|
+
seen.push(opts);
|
|
77
|
+
return real(cmd, args, opts);
|
|
78
|
+
}));
|
|
79
|
+
try {
|
|
80
|
+
fn();
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
spy.mockRestore();
|
|
84
|
+
}
|
|
85
|
+
return seen;
|
|
86
|
+
};
|
|
87
|
+
test('execGit returns stdout and passes stdin closed, stdout piped, stderr as asked', () => {
|
|
88
|
+
const repo = (0, helpers_1.makeRepo)();
|
|
89
|
+
try {
|
|
90
|
+
const piped = optionsOfCalls(() => expect((0, git_env_1.execGit)(repo.root, ['rev-parse', 'HEAD'])).toBe(repo.git('rev-parse', 'HEAD') + '\n'));
|
|
91
|
+
expect(piped.map((o) => o.stdio)).toEqual([['ignore', 'pipe', 'pipe']]);
|
|
92
|
+
const ignored = optionsOfCalls(() => { (0, git_env_1.execGit)(repo.root, ['rev-parse', 'HEAD'], { stderr: 'ignore' }); });
|
|
93
|
+
expect(ignored.map((o) => o.stdio)).toEqual([['ignore', 'pipe', 'ignore']]);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
repo.cleanup();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test('runGit and gitState keep stdin closed and stdout piped on every child; gitState discards stderr', () => {
|
|
100
|
+
const repo = (0, helpers_1.makeRepo)();
|
|
101
|
+
try {
|
|
102
|
+
const run = optionsOfCalls(() => { expect((0, git_1.runGit)(repo.root, ['rev-parse', 'HEAD'])).not.toBeNull(); });
|
|
103
|
+
expect(run).toHaveLength(1);
|
|
104
|
+
expect(run[0]).toMatchObject({ cwd: repo.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: git_env_1.GIT_TIMEOUT_MS });
|
|
105
|
+
const state = optionsOfCalls(() => { expect((0, worktree_1.gitState)(repo.root, true).kind).toBe('ok'); });
|
|
106
|
+
expect(state).toHaveLength(3);
|
|
107
|
+
for (const o of state)
|
|
108
|
+
expect(o).toMatchObject({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: git_env_1.GIT_TIMEOUT_MS });
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
repo.cleanup();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('an inherited GIT_DIR cannot redirect the change-record code to another repository', () => {
|
|
116
|
+
test('runGit, headSha, gitState, the receipt precondition and `change validate` all describe the cwd repository', () => {
|
|
117
|
+
const mine = (0, helpers_1.makeRepo)();
|
|
118
|
+
const decoy = (0, helpers_1.makeRepo)();
|
|
119
|
+
try {
|
|
120
|
+
decoy.commit({ 'decoy.txt': 'x' }, 'feat: the decoy has a different history');
|
|
121
|
+
const head = mine.git('rev-parse', 'HEAD');
|
|
122
|
+
const decoyHead = decoy.git('rev-parse', 'HEAD');
|
|
123
|
+
expect(decoyHead).not.toBe(head);
|
|
124
|
+
const validate = () => (0, change_1.runChangeValidate)(mine.root, undefined, { json: true });
|
|
125
|
+
const baseline = validate();
|
|
126
|
+
const hostile = { GIT_DIR: path_1.default.join(decoy.root, '.git'), GIT_WORK_TREE: decoy.root, GIT_INDEX_FILE: path_1.default.join(decoy.root, '.git', 'index') };
|
|
127
|
+
(0, helpers_1.withEnv)(hostile, () => {
|
|
128
|
+
expect((0, git_1.runGit)(mine.root, ['rev-parse', 'HEAD'])?.trim()).toBe(head);
|
|
129
|
+
expect((0, git_1.headSha)(mine.root)).toBe(head);
|
|
130
|
+
const state = (0, worktree_1.gitState)(mine.root, true);
|
|
131
|
+
expect(state.kind === 'ok' && state.commit).toBe(head);
|
|
132
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(mine.root);
|
|
133
|
+
expect(pre.eligible && pre.commit).toBe(head);
|
|
134
|
+
expect(pre.eligible && fs_1.default.realpathSync.native(pre.root)).toBe(mine.root);
|
|
135
|
+
expect(validate()).toEqual(baseline);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
decoy.cleanup();
|
|
140
|
+
mine.cleanup();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const helpers_1 = require("./helpers");
|
|
10
|
+
const git_1 = require("../../../src/core/changes/git");
|
|
11
|
+
const ANY_DIR = os_1.default.tmpdir();
|
|
12
|
+
const NOT_STRINGS = [undefined, null, 7, {}, ['main'], true];
|
|
13
|
+
const UNSAFE_REFS = ['--upload-pack=x', '-x', '-', 'a..b', 'a b', 'main\n', 'x\ny', 'a\u0000b', 'a;b', '$(id)', 'a`b`', 'main^', 'HEAD~1', 'a:b', '', 'a'.repeat(300), 'ma\u0301in'];
|
|
14
|
+
/** The base ref `resolveBaseCommit` picks (R3.1), or null. */
|
|
15
|
+
const baseOf = (root, explicit) => (0, git_1.resolveBaseCommit)(root, explicit)?.base ?? null;
|
|
16
|
+
describe('ref and revision predicates (no repository)', () => {
|
|
17
|
+
test('isSafeRef accepts plain ref names only', () => {
|
|
18
|
+
for (const ok of ['main', 'refs/remotes/origin/HEAD', 'feat/x-1.2', 'a'.repeat(40), '.hidden'])
|
|
19
|
+
expect((0, git_1.isSafeRef)(ok)).toBe(true);
|
|
20
|
+
for (const no of ['', '-x', '--a', 'a..b', 'a b', 'a\nb', 'a^', 'a~1', 'a@{1}', 'a:b', 'a'.repeat(256), 7, null, undefined, {}])
|
|
21
|
+
expect((0, git_1.isSafeRef)(no)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test('isSafeRevision accepts a ref or a two-sided range', () => {
|
|
24
|
+
for (const ok of ['main', 'a..b', 'a...b', 'refs/heads/x..HEAD'])
|
|
25
|
+
expect((0, git_1.isSafeRevision)(ok)).toBe(true);
|
|
26
|
+
for (const no of ['', '..', 'a..', '..b', 'a..b..c', '-x..b', 'a..-x', 'a b..c', 7, null])
|
|
27
|
+
expect((0, git_1.isSafeRevision)(no)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test('LOG_SAFETY_ARGS switches signature display off and fixes the encoding', () => {
|
|
30
|
+
expect([...git_1.LOG_SAFETY_ARGS]).toEqual(['--no-show-signature', '--encoding=UTF-8']);
|
|
31
|
+
});
|
|
32
|
+
test.each(NOT_STRINGS)('returns null for a non-string cwd (%p)', (cwd) => {
|
|
33
|
+
expect((0, git_1.runGit)((0, helpers_1.bad)(cwd), ['status'])).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
test('returns null for a non-array args value', () => {
|
|
36
|
+
expect((0, git_1.runGit)(ANY_DIR, (0, helpers_1.bad)('status'))).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
test('returns null when an argument is not a string or holds a NUL', () => {
|
|
39
|
+
expect((0, git_1.runGit)(ANY_DIR, (0, helpers_1.bad)(['status', 7]))).toBeNull();
|
|
40
|
+
expect((0, git_1.runGit)(ANY_DIR, ['status', 'a\u0000b'])).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('git functions in a repository', () => {
|
|
44
|
+
let repo;
|
|
45
|
+
let template;
|
|
46
|
+
beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
|
|
47
|
+
afterAll(() => template.cleanup());
|
|
48
|
+
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
49
|
+
afterEach(() => repo.cleanup());
|
|
50
|
+
const branch = () => (0, helpers_1.branchOff)(repo);
|
|
51
|
+
const noNewFile = (name) => expect(fs_1.default.existsSync(path_1.default.join(repo.root, name))).toBe(false);
|
|
52
|
+
const nonRepoDir = () => (0, helpers_1.nonRepoDir)(repo);
|
|
53
|
+
test('returns the output of a successful command', () => {
|
|
54
|
+
expect((0, git_1.runGit)(repo.root, ['rev-parse', '--is-inside-work-tree'])?.trim()).toBe('true');
|
|
55
|
+
});
|
|
56
|
+
test('reads output above Node\'s 1 MB default and gives up (null) above 16 MB', () => {
|
|
57
|
+
const blob = (megabytes) => {
|
|
58
|
+
const file = path_1.default.join(repo.home, `blob-${megabytes}`);
|
|
59
|
+
fs_1.default.writeFileSync(file, 'x'.repeat(megabytes * 1024 * 1024));
|
|
60
|
+
return repo.git('hash-object', '-w', file);
|
|
61
|
+
};
|
|
62
|
+
expect((0, git_1.runGit)(repo.root, ['cat-file', 'blob', blob(2)])?.length).toBe(2 * 1024 * 1024);
|
|
63
|
+
expect((0, git_1.runGit)(repo.root, ['cat-file', 'blob', blob(17)])).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
test('returns null when git fails', () => {
|
|
66
|
+
expect((0, git_1.runGit)(repo.root, ['rev-parse', '--verify', '--quiet', 'nope-ref'])).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
test('returns null for a directory that does not exist', () => {
|
|
69
|
+
expect((0, git_1.runGit)(path_1.default.join(repo.home, 'missing'), ['status'])).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
test('R3.1 resolves base: explicit, origin/HEAD, main, master', () => {
|
|
72
|
+
branch();
|
|
73
|
+
expect(baseOf(repo.root)).toBe('main');
|
|
74
|
+
expect(baseOf(repo.root, 'main')).toBe('main');
|
|
75
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
76
|
+
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
77
|
+
expect(baseOf(repo.root)).toBe('refs/remotes/origin/HEAD');
|
|
78
|
+
});
|
|
79
|
+
test('R3.1 an explicit base wins over origin/HEAD and main', () => {
|
|
80
|
+
branch();
|
|
81
|
+
repo.git('branch', 'release');
|
|
82
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
83
|
+
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
84
|
+
expect(baseOf(repo.root, 'release')).toBe('release');
|
|
85
|
+
});
|
|
86
|
+
test('R3.1 main wins over master and master is the last resort', () => {
|
|
87
|
+
repo.git('branch', 'master');
|
|
88
|
+
expect(baseOf(repo.root)).toBe('main');
|
|
89
|
+
repo.git('branch', '-m', 'main', 'trunk');
|
|
90
|
+
expect(baseOf(repo.root)).toBe('master');
|
|
91
|
+
});
|
|
92
|
+
test('R3.1 origin/HEAD that points nowhere is skipped', () => {
|
|
93
|
+
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/gone');
|
|
94
|
+
expect(baseOf(repo.root)).toBe('main');
|
|
95
|
+
});
|
|
96
|
+
test('R3.2 unresolvable base returns null', () => {
|
|
97
|
+
repo.git('branch', '-m', 'main', 'trunk');
|
|
98
|
+
expect(baseOf(repo.root)).toBeNull();
|
|
99
|
+
expect(baseOf(repo.root, 'nope')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
test.each(UNSAFE_REFS)('R3.2 unsafe explicit base %j returns null without falling back to main', (ref) => {
|
|
102
|
+
repo.commit({ 'second.ts': '1' }, 'feat: second');
|
|
103
|
+
expect(baseOf(repo.root, ref)).toBeNull();
|
|
104
|
+
noNewFile('x');
|
|
105
|
+
});
|
|
106
|
+
test.each(NOT_STRINGS.filter((v) => v !== undefined))('R3.2 non-string explicit base (%p) returns null', (ref) => {
|
|
107
|
+
expect(baseOf(repo.root, (0, helpers_1.bad)(ref))).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
test('R3.2 a non-string root returns null', () => {
|
|
110
|
+
expect(baseOf((0, helpers_1.bad)(null))).toBeNull();
|
|
111
|
+
expect(baseOf(nonRepoDir())).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
test('R3.1 a ref that names a tree rather than a commit is not a base', () => {
|
|
114
|
+
const tree = repo.git('rev-parse', 'HEAD^{tree}');
|
|
115
|
+
repo.git('update-ref', 'refs/tags/treeish', tree);
|
|
116
|
+
expect(baseOf(repo.root, 'treeish')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
test('mergeBase returns the fork point, not the tip of the base', () => {
|
|
119
|
+
const fork = branch();
|
|
120
|
+
repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
121
|
+
repo.git('checkout', '-q', 'main');
|
|
122
|
+
repo.commit({ 'm.ts': '1' }, 'feat: moves main');
|
|
123
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
124
|
+
expect((0, git_1.mergeBase)(repo.root, 'main')).toBe(fork);
|
|
125
|
+
expect((0, git_1.mergeBase)(repo.root, 'main')).not.toBe(repo.git('rev-parse', 'main'));
|
|
126
|
+
});
|
|
127
|
+
test('mergeBase returns null for unrelated histories, unknown, unsafe or non-string refs', () => {
|
|
128
|
+
repo.git('checkout', '-q', '--orphan', 'orphan');
|
|
129
|
+
repo.commit({ 'o.ts': '1' }, 'feat: orphan');
|
|
130
|
+
expect((0, git_1.mergeBase)(repo.root, 'main')).toBeNull();
|
|
131
|
+
expect((0, git_1.mergeBase)(repo.root, 'nope')).toBeNull();
|
|
132
|
+
expect((0, git_1.mergeBase)(repo.root, '--output=x')).toBeNull();
|
|
133
|
+
expect((0, git_1.mergeBase)(repo.root, 'a..b')).toBeNull();
|
|
134
|
+
repo.commit({ 'second.ts': '1' }, 'feat: second');
|
|
135
|
+
expect((0, git_1.mergeBase)(repo.root, 'HEAD~1')).toBeNull();
|
|
136
|
+
expect((0, git_1.mergeBase)(repo.root, (0, helpers_1.bad)(7))).toBeNull();
|
|
137
|
+
expect((0, git_1.mergeBase)((0, helpers_1.bad)(undefined), 'main')).toBeNull();
|
|
138
|
+
noNewFile('x');
|
|
139
|
+
});
|
|
140
|
+
test('ancestry', () => {
|
|
141
|
+
const base = branch();
|
|
142
|
+
const c1 = repo.commit({ 'src/a.ts': '1' }, 'feat: a');
|
|
143
|
+
expect((0, git_1.isAncestor)(repo.root, base, c1)).toBe(true);
|
|
144
|
+
expect((0, git_1.isAncestor)(repo.root, c1, base)).toBe(false);
|
|
145
|
+
expect((0, git_1.isAncestor)(repo.root, 'f'.repeat(40), c1)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
test('ancestry is false for unsafe or non-string arguments', () => {
|
|
148
|
+
const base = branch();
|
|
149
|
+
repo.commit({ 'second.ts': '1' }, 'feat: second');
|
|
150
|
+
expect((0, git_1.isAncestor)(repo.root, 'HEAD~1', 'HEAD')).toBe(false);
|
|
151
|
+
expect((0, git_1.isAncestor)(repo.root, 'main', 'HEAD^')).toBe(false);
|
|
152
|
+
expect((0, git_1.isAncestor)(repo.root, '--help', 'HEAD')).toBe(false);
|
|
153
|
+
expect((0, git_1.isAncestor)(repo.root, base, '-x')).toBe(false);
|
|
154
|
+
expect((0, git_1.isAncestor)(repo.root, 'a..b', 'HEAD')).toBe(false);
|
|
155
|
+
expect((0, git_1.isAncestor)(repo.root, (0, helpers_1.bad)(null), 'HEAD')).toBe(false);
|
|
156
|
+
expect((0, git_1.isAncestor)(repo.root, base, (0, helpers_1.bad)(7))).toBe(false);
|
|
157
|
+
expect((0, git_1.isAncestor)((0, helpers_1.bad)({}), base, 'HEAD')).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
test('changedFiles lists the files of the range', () => {
|
|
160
|
+
const base = branch();
|
|
161
|
+
repo.commit({ 'src/a.ts': '1' }, 'feat: a');
|
|
162
|
+
expect((0, git_1.changedFiles)(repo.root, base, 'HEAD')).toEqual(['src/a.ts']);
|
|
163
|
+
});
|
|
164
|
+
test('changedFiles reports a rename as the old and the new path', () => {
|
|
165
|
+
repo.commit({ 'old.ts': 'same content, long enough to be detected as a rename\n'.repeat(5) }, 'feat: old');
|
|
166
|
+
const base = branch();
|
|
167
|
+
repo.git('mv', 'old.ts', 'new.ts');
|
|
168
|
+
repo.git('commit', '-q', '-m', 'refactor: rename');
|
|
169
|
+
expect((0, git_1.changedFiles)(repo.root, base)?.slice().sort()).toEqual(['new.ts', 'old.ts']);
|
|
170
|
+
});
|
|
171
|
+
test('changedFiles returns paths verbatim: spaces, unicode and (off Windows) a newline', () => {
|
|
172
|
+
const base = branch();
|
|
173
|
+
const names = ['dir with space/ñandú.ts'];
|
|
174
|
+
if (process.platform !== 'win32')
|
|
175
|
+
names.push('line\nbreak/x.ts');
|
|
176
|
+
repo.commit(Object.fromEntries(names.map((n) => [n, '1'])), 'feat: odd names');
|
|
177
|
+
expect((0, git_1.changedFiles)(repo.root, base)?.slice().sort()).toEqual([...names].sort());
|
|
178
|
+
});
|
|
179
|
+
test('changedFiles returns null for unsafe, unknown or non-string revisions and for a non-repo', () => {
|
|
180
|
+
branch();
|
|
181
|
+
expect((0, git_1.changedFiles)(repo.root, '--output=x', 'HEAD')).toBeNull();
|
|
182
|
+
expect((0, git_1.changedFiles)(repo.root, 'HEAD', '--output=x')).toBeNull();
|
|
183
|
+
expect((0, git_1.changedFiles)(repo.root, 'nope', 'HEAD')).toBeNull();
|
|
184
|
+
expect((0, git_1.changedFiles)(repo.root, (0, helpers_1.bad)(7), 'HEAD')).toBeNull();
|
|
185
|
+
repo.commit({ 'second.ts': '1' }, 'feat: second');
|
|
186
|
+
expect((0, git_1.changedFiles)(repo.root, 'HEAD~1', 'HEAD')).toBeNull();
|
|
187
|
+
expect((0, git_1.changedFiles)(repo.root, 'HEAD', 'HEAD^')).toBeNull();
|
|
188
|
+
expect((0, git_1.changedFiles)(nonRepoDir(), 'main', 'HEAD')).toBeNull();
|
|
189
|
+
noNewFile('x');
|
|
190
|
+
});
|
|
191
|
+
test('addedFilesUnder lists only files ADDED in the range under the directory', () => {
|
|
192
|
+
repo.commit({ 'docs/changes/old.md': '1', 'docs/other/x.md': '1' }, 'docs: existing');
|
|
193
|
+
const base = branch();
|
|
194
|
+
repo.commit({ 'docs/changes/new.md': '1', 'docs/changes/old.md': '2', 'docs/other/y.md': '1', 'src/a.ts': '1' }, 'feat: a');
|
|
195
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, 'HEAD', 'docs/changes')).toEqual(['docs/changes/new.md']);
|
|
196
|
+
});
|
|
197
|
+
test('addedFilesUnder reports a file moved into the directory as added', () => {
|
|
198
|
+
repo.commit({ 'other/r.md': 'same content, long enough to be detected as a rename\n'.repeat(5) }, 'feat: r');
|
|
199
|
+
const base = branch();
|
|
200
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs/changes'), { recursive: true });
|
|
201
|
+
repo.git('mv', 'other/r.md', 'docs/changes/r.md');
|
|
202
|
+
repo.git('commit', '-q', '-m', 'refactor: move');
|
|
203
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, 'HEAD', 'docs/changes')).toEqual(['docs/changes/r.md']);
|
|
204
|
+
});
|
|
205
|
+
test('addedFilesUnder returns null for unsafe revisions, an unsafe directory, an unknown revision and a non-repo', () => {
|
|
206
|
+
const base = branch();
|
|
207
|
+
repo.commit({ 'docs/changes/new.md': '1' }, 'feat: a');
|
|
208
|
+
expect((0, git_1.addedFilesUnder)(repo.root, '--output=x', 'HEAD', 'docs/changes')).toBeNull();
|
|
209
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, '-x', 'docs/changes')).toBeNull();
|
|
210
|
+
expect((0, git_1.addedFilesUnder)(repo.root, 'nope', 'HEAD', 'docs/changes')).toBeNull();
|
|
211
|
+
expect((0, git_1.addedFilesUnder)(repo.root, (0, helpers_1.bad)(7), 'HEAD', 'docs/changes')).toBeNull();
|
|
212
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, 'HEAD', '../x')).toBeNull();
|
|
213
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, 'HEAD', (0, helpers_1.bad)(null))).toBeNull();
|
|
214
|
+
expect((0, git_1.addedFilesUnder)(nonRepoDir(), 'main', 'HEAD', 'docs/changes')).toBeNull();
|
|
215
|
+
expect((0, git_1.addedFilesUnder)(repo.root, base, 'HEAD', 'docs/changes')).toEqual(['docs/changes/new.md']);
|
|
216
|
+
});
|
|
217
|
+
test('isCommittedClean is true for an unchanged tracked file and false after an unstaged or a staged edit', () => {
|
|
218
|
+
branch();
|
|
219
|
+
repo.commit({ 'docs/changes/r.md': 'one' }, 'docs: r');
|
|
220
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(true);
|
|
221
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/r.md'), 'two');
|
|
222
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(false);
|
|
223
|
+
repo.git('add', 'docs/changes/r.md');
|
|
224
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(false);
|
|
225
|
+
repo.git('commit', '-q', '-m', 'docs: r2');
|
|
226
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
test('isCommittedClean looks only at the given path, literally', () => {
|
|
229
|
+
branch();
|
|
230
|
+
repo.commit({ 'docs/changes/r.md': 'one', 'docs/changes/s.md': 'one', 'docs/changes/[a].md': 'one', 'docs/changes/a.md': 'one' }, 'docs: files');
|
|
231
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/s.md'), 'two');
|
|
232
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/a.md'), 'two');
|
|
233
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(true);
|
|
234
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/[a].md')).toBe(true); // a glob would match a.md
|
|
235
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/s.md')).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
test('isCommittedClean ignores untracked files and runs from any directory of the repository', () => {
|
|
238
|
+
branch();
|
|
239
|
+
repo.commit({ 'docs/changes/r.md': 'one' }, 'docs: r');
|
|
240
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/new.md'), 'x');
|
|
241
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/new.md')).toBe(true);
|
|
242
|
+
expect((0, git_1.isCommittedClean)(path_1.default.join(repo.root, 'docs'), 'docs/changes/r.md')).toBe(true);
|
|
243
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/r.md'), 'two');
|
|
244
|
+
expect((0, git_1.isCommittedClean)(path_1.default.join(repo.root, 'docs'), 'docs/changes/r.md')).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
test('isCommittedClean is null for an unsafe path, a non-string input and a non-repository', () => {
|
|
247
|
+
branch();
|
|
248
|
+
for (const rel of ['../x', '/etc/passwd', '', 'a\nb', '-x/../y', (0, helpers_1.bad)(7), (0, helpers_1.bad)(null)])
|
|
249
|
+
expect((0, git_1.isCommittedClean)(repo.root, rel)).toBeNull();
|
|
250
|
+
expect((0, git_1.isCommittedClean)((0, helpers_1.bad)(undefined), 'docs/changes/r.md')).toBeNull();
|
|
251
|
+
expect((0, git_1.isCommittedClean)(nonRepoDir(), 'docs/changes/r.md')).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
test('isCommittedClean never takes the optional index lock (status would otherwise refresh and write the index)', () => {
|
|
254
|
+
branch();
|
|
255
|
+
repo.commit({ 'docs/changes/r.md': 'one' }, 'docs: r');
|
|
256
|
+
const git = (0, helpers_1.spyOnGit)();
|
|
257
|
+
try {
|
|
258
|
+
(0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md');
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
git.restore();
|
|
262
|
+
}
|
|
263
|
+
const { calls } = git;
|
|
264
|
+
expect(calls).toHaveLength(1);
|
|
265
|
+
expect(calls[0][0]).toBe('--no-optional-locks');
|
|
266
|
+
expect(calls[0]).toEqual(expect.arrayContaining(['status', '--porcelain', '-z', '--untracked-files=no', '--ignore-submodules=none']));
|
|
267
|
+
});
|
|
268
|
+
test('isCommittedClean leaves the index file untouched even when its stat data is stale', () => {
|
|
269
|
+
branch();
|
|
270
|
+
repo.commit({ 'docs/changes/r.md': 'one' }, 'docs: r');
|
|
271
|
+
const index = path_1.default.join(repo.root, '.git', 'index');
|
|
272
|
+
fs_1.default.utimesSync(path_1.default.join(repo.root, 'docs/changes/r.md'), new Date(2000, 0, 1), new Date(2000, 0, 1));
|
|
273
|
+
const before = fs_1.default.readFileSync(index);
|
|
274
|
+
expect((0, git_1.isCommittedClean)(repo.root, 'docs/changes/r.md')).toBe(true);
|
|
275
|
+
expect(fs_1.default.readFileSync(index).equals(before)).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
test('dirtiness ignores only the excluded paths', () => {
|
|
278
|
+
branch();
|
|
279
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['docs/changes'])).toBe(false);
|
|
280
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs/changes'), { recursive: true });
|
|
281
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/r.md'), 'r');
|
|
282
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['docs/changes'])).toBe(false);
|
|
283
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(true);
|
|
284
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs-x.md'), 'y');
|
|
285
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['.awm', 'docs/changes'])).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
test('dirtiness: runtime state under .awm is ignored but a modified tracked sensors.json is dirty', () => {
|
|
288
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, '.awm/journal'), { recursive: true });
|
|
289
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/sensors.json'), '{}');
|
|
290
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/journal/j.json'), '1');
|
|
291
|
+
repo.git('add', '-f', '.awm/sensors.json', '.awm/journal/j.json');
|
|
292
|
+
repo.git('commit', '-q', '-m', 'chore: track awm files');
|
|
293
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/journal/j.json'), '2');
|
|
294
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(false);
|
|
295
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/sensors.json'), '{"a":1}');
|
|
296
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
test('dirtiness: runtime state stays excluded when extra exclusions are given too', () => {
|
|
299
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, '.awm/journal'), { recursive: true });
|
|
300
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/journal/j.json'), '1');
|
|
301
|
+
repo.git('add', '-f', '.awm/journal/j.json');
|
|
302
|
+
repo.git('commit', '-q', '-m', 'chore: track journal');
|
|
303
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm/journal/j.json'), '2');
|
|
304
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs/changes'), { recursive: true });
|
|
305
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/r.md'), 'r');
|
|
306
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['docs/changes'])).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
test('dirtiness: a submodule checked out at another commit is dirty even with ignore=all and diff.ignoreSubmodules=all', () => {
|
|
309
|
+
const { commitInSm } = (0, helpers_1.makeHiddenSubmodule)(repo.root, (cwd, ...args) => repo.git('-C', cwd, ...args));
|
|
310
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(false);
|
|
311
|
+
commitInSm('two');
|
|
312
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(true);
|
|
313
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['docs/changes'])).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
test('dirtiness is reported (fail closed) when the state cannot be read or the excludes are unsafe', () => {
|
|
316
|
+
expect((0, git_1.isDirtyOutside)(nonRepoDir(), [])).toBe(true);
|
|
317
|
+
expect((0, git_1.isDirtyOutside)((0, helpers_1.bad)(null), [])).toBe(true);
|
|
318
|
+
expect((0, git_1.isDirtyOutside)(repo.root, (0, helpers_1.bad)('docs/changes'))).toBe(true);
|
|
319
|
+
expect((0, git_1.isDirtyOutside)(repo.root, (0, helpers_1.bad)([7]))).toBe(true);
|
|
320
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['../outside'])).toBe(true);
|
|
321
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['a\nb'])).toBe(true);
|
|
322
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['/abs'])).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
test('repoRoot and headSha describe the repository, from a subdirectory too', () => {
|
|
325
|
+
const sub = path_1.default.join(repo.root, 'deep', 'er');
|
|
326
|
+
fs_1.default.mkdirSync(sub, { recursive: true });
|
|
327
|
+
expect(fs_1.default.realpathSync.native((0, git_1.repoRoot)(repo.root))).toBe(fs_1.default.realpathSync.native(repo.root));
|
|
328
|
+
expect(fs_1.default.realpathSync.native((0, git_1.repoRoot)(sub))).toBe(fs_1.default.realpathSync.native(repo.root));
|
|
329
|
+
expect(path_1.default.isAbsolute((0, git_1.repoRoot)(sub))).toBe(true);
|
|
330
|
+
expect((0, git_1.headSha)(sub)).toBe(repo.git('rev-parse', 'HEAD'));
|
|
331
|
+
});
|
|
332
|
+
test('repoRoot and headSha never run git status, so a slow or failing status cannot hide a readable HEAD', () => {
|
|
333
|
+
const git = (0, helpers_1.spyOnGit)((args) => { if (args[0] === 'status')
|
|
334
|
+
throw Object.assign(new Error('status must not run'), { code: 'ETIMEDOUT' }); });
|
|
335
|
+
try {
|
|
336
|
+
expect(fs_1.default.realpathSync.native((0, git_1.repoRoot)(repo.root))).toBe(fs_1.default.realpathSync.native(repo.root));
|
|
337
|
+
expect((0, git_1.headSha)(repo.root)).toMatch(/^[0-9a-f]{40}$/);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
git.restore();
|
|
341
|
+
}
|
|
342
|
+
const seen = git.calls.flat();
|
|
343
|
+
expect(seen).not.toContain('status');
|
|
344
|
+
expect(seen).toContain('rev-parse');
|
|
345
|
+
});
|
|
346
|
+
test('repoRoot works in a repository without a commit; headSha is null there', () => {
|
|
347
|
+
const empty = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'empty-'));
|
|
348
|
+
repo.git('-C', empty, 'init', '-q', '-b', 'main');
|
|
349
|
+
expect(fs_1.default.realpathSync.native((0, git_1.repoRoot)(empty))).toBe(fs_1.default.realpathSync.native(empty));
|
|
350
|
+
expect((0, git_1.headSha)(empty)).toBeNull();
|
|
351
|
+
});
|
|
352
|
+
test('repoRoot and headSha are null outside a repository and for non-string input', () => {
|
|
353
|
+
expect((0, git_1.repoRoot)(nonRepoDir())).toBeNull();
|
|
354
|
+
expect((0, git_1.headSha)(nonRepoDir())).toBeNull();
|
|
355
|
+
expect((0, git_1.repoRoot)((0, helpers_1.bad)(undefined))).toBeNull();
|
|
356
|
+
expect((0, git_1.headSha)((0, helpers_1.bad)(7))).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
describe('repoIdentity', () => {
|
|
359
|
+
const identity = (url) => { repo.git('remote', 'add', 'origin', url); return (0, git_1.repoIdentity)(repo.root); };
|
|
360
|
+
test('is null without a remote', () => expect((0, git_1.repoIdentity)(repo.root)).toBeNull());
|
|
361
|
+
test('reads an https remote and strips credentials and .git', () => {
|
|
362
|
+
expect(identity('https://user:secret@github.com/Kodria/awm.git')).toBe('github.com/Kodria/awm');
|
|
363
|
+
});
|
|
364
|
+
test('reads an scp-style remote', () => expect(identity('git@github.com:Kodria/awm.git')).toBe('github.com/Kodria/awm'));
|
|
365
|
+
test('reads an ssh:// remote with a port', () => expect(identity('ssh://git@host.example:2222/o/r')).toBe('host.example/o/r'));
|
|
366
|
+
test('is null for an unparseable remote', () => expect(identity('not a url')).toBeNull());
|
|
367
|
+
test('lowercases the host in the scp-style and the URL form alike, so both name the same repository', () => {
|
|
368
|
+
expect(identity('git@GitHub.com:o/r.git')).toBe('github.com/o/r');
|
|
369
|
+
const other = (0, helpers_1.makeRepo)();
|
|
370
|
+
try {
|
|
371
|
+
other.git('remote', 'add', 'origin', 'https://GITHUB.com/o/r');
|
|
372
|
+
expect((0, git_1.repoIdentity)(other.root)).toBe('github.com/o/r');
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
other.cleanup();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
test('strips credentials in the scp-style form too', () => {
|
|
379
|
+
expect(identity('user:secret@Host.example:o/r')).toBe('host.example/o/r');
|
|
380
|
+
});
|
|
381
|
+
test.each([
|
|
382
|
+
['an scp-style remote with a ?query', 'git@host.example:o/r?token=SECRET'],
|
|
383
|
+
['an scp-style remote with a #fragment', 'git@host.example:o/r#SECRET'],
|
|
384
|
+
['an scp-style remote with .git then ?query', 'git@host.example:o/r.git?token=SECRET'],
|
|
385
|
+
['an scp-style remote with a query and a fragment', 'git@host.example:o/r?a=1#SECRET'],
|
|
386
|
+
['an https remote with a ?query', 'https://host.example/o/r?token=SECRET'],
|
|
387
|
+
['an https remote with a #fragment', 'https://host.example/o/r#SECRET'],
|
|
388
|
+
['an https remote with credentials, .git and a query', 'https://u:SECRET@host.example/o/r.git?token=SECRET'],
|
|
389
|
+
])('R5.5 %s: the identity holds host/owner/name and no query or fragment', (_label, url) => {
|
|
390
|
+
const id = identity(url);
|
|
391
|
+
expect(id).toBe('host.example/o/r');
|
|
392
|
+
expect(id).not.toContain('SECRET');
|
|
393
|
+
});
|
|
394
|
+
test('an over-long remote url is null (git reads it, the 2048-unit cap rejects it)', () => {
|
|
395
|
+
fs_1.default.appendFileSync(path_1.default.join(repo.root, '.git', 'config'), `[remote "origin"]\n\turl = https://h.example/${'/'.repeat(100000)}x\n`);
|
|
396
|
+
expect((0, git_1.repoIdentity)(repo.root)).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
test('identityFromUrl: a hostile url is rejected, whatever its length', () => {
|
|
399
|
+
expect((0, git_1.identityFromUrl)(`https://h.example/${'/'.repeat(100000)}x`)).toBeNull();
|
|
400
|
+
expect((0, git_1.identityFromUrl)(`git@h.example:${'/'.repeat(100000)}x`)).toBeNull();
|
|
401
|
+
expect((0, git_1.identityFromUrl)(`https://h.example/${'/'.repeat(1000)}o/r`)).toBe('h.example/o/r');
|
|
402
|
+
});
|
|
403
|
+
// The 2048-unit cap returns before any scan, so the url-level tests above cannot show that the scan is linear: the helper
|
|
404
|
+
// that does the scanning is measured on its own, on inputs far beyond what the cap lets through.
|
|
405
|
+
test('trimSlashes removes the runs of slashes at both ends and scales linearly', () => {
|
|
406
|
+
expect((0, git_1.trimSlashes)('///a/b///')).toBe('a/b');
|
|
407
|
+
expect((0, git_1.trimSlashes)('////')).toBe('');
|
|
408
|
+
expect((0, git_1.trimSlashes)('a')).toBe('a');
|
|
409
|
+
expect((0, git_1.trimSlashes)(`x${'/'.repeat(5000)}y`)).toBe(`x${'/'.repeat(5000)}y`);
|
|
410
|
+
(0, helpers_1.expectLinearScaling)((n) => { (0, git_1.trimSlashes)(`x${'/'.repeat(n)}y`); (0, git_1.trimSlashes)(`${'/'.repeat(n)}x`); (0, git_1.trimSlashes)(`x${'/'.repeat(n)}`); }, 20_000);
|
|
411
|
+
});
|
|
412
|
+
test.each([
|
|
413
|
+
['a ? in the scp host', 'git@github.com?token=abc:o/r'],
|
|
414
|
+
['a # in the scp host', 'git@github.com#tok:o/r'],
|
|
415
|
+
['a second @ in the scp host', 'git@evil@github.com:o/r'],
|
|
416
|
+
['credentials with a # in the host', 'user:pw@host#frag:o/r'],
|
|
417
|
+
])('R5.5 %s makes the identity null (a token must never reach repo)', (_label, url) => {
|
|
418
|
+
expect((0, git_1.identityFromUrl)(url)).toBeNull();
|
|
419
|
+
expect(identity(url)).toBeNull();
|
|
420
|
+
});
|
|
421
|
+
test('is null for a non-string root or outside a repository', () => {
|
|
422
|
+
expect((0, git_1.repoIdentity)((0, helpers_1.bad)(null))).toBeNull();
|
|
423
|
+
expect((0, git_1.repoIdentity)(nonRepoDir())).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
test('is null when the remote path carries a control character', () => {
|
|
426
|
+
expect(identity('git@host:o/r\u0001x.git')).toBeNull();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
test.each(['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'])('R3.4 %s=1 does not change the file list', (name) => {
|
|
430
|
+
const base = branch();
|
|
431
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'feat(c): a');
|
|
432
|
+
(0, helpers_1.withEnv)({ [name]: '1' }, () => expect((0, git_1.changedFiles)(repo.root, base)).toEqual(['cli/src/a.ts']));
|
|
433
|
+
});
|
|
434
|
+
test('dirtiness with extra exclusions is not changed by GIT_LITERAL_PATHSPECS', () => {
|
|
435
|
+
fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs/changes'), { recursive: true });
|
|
436
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs/changes/r.md'), 'r');
|
|
437
|
+
(0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1' }, () => {
|
|
438
|
+
expect((0, git_1.isDirtyOutside)(repo.root, ['docs/changes'])).toBe(false);
|
|
439
|
+
expect((0, git_1.isDirtyOutside)(repo.root, [])).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|