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.
Files changed (59) hide show
  1. package/dist/src/commands/change/index.js +492 -0
  2. package/dist/src/commands/changes/index.js +84 -0
  3. package/dist/src/commands/sensors/index.js +9 -0
  4. package/dist/src/commands/sensors/run.js +1 -0
  5. package/dist/src/core/changes/command.js +38 -0
  6. package/dist/src/core/changes/derive.js +120 -0
  7. package/dist/src/core/changes/fs-safe.js +47 -0
  8. package/dist/src/core/changes/git-env.js +68 -0
  9. package/dist/src/core/changes/git.js +216 -0
  10. package/dist/src/core/changes/list-parse.js +67 -0
  11. package/dist/src/core/changes/list.js +408 -0
  12. package/dist/src/core/changes/parse.js +72 -0
  13. package/dist/src/core/changes/range.js +53 -0
  14. package/dist/src/core/changes/receipt.js +274 -0
  15. package/dist/src/core/changes/record-io.js +149 -0
  16. package/dist/src/core/changes/render-list.js +109 -0
  17. package/dist/src/core/changes/render.js +178 -0
  18. package/dist/src/core/changes/text-safety.js +116 -0
  19. package/dist/src/core/changes/trailers.js +76 -0
  20. package/dist/src/core/changes/types.js +26 -0
  21. package/dist/src/core/changes/validate-shape.js +219 -0
  22. package/dist/src/core/changes/worktree.js +53 -0
  23. package/dist/src/index.js +4 -0
  24. package/dist/src/release/orchestrator.js +3 -1
  25. package/dist/tests/commands/change/new.test.js +1126 -0
  26. package/dist/tests/commands/change/validate.test.js +1145 -0
  27. package/dist/tests/commands/changes/list.test.js +423 -0
  28. package/dist/tests/commands/sensors/index.test.js +1 -1
  29. package/dist/tests/core/changes/derive.test.js +455 -0
  30. package/dist/tests/core/changes/fs-safe.test.js +82 -0
  31. package/dist/tests/core/changes/git-env.test.js +143 -0
  32. package/dist/tests/core/changes/git.test.js +442 -0
  33. package/dist/tests/core/changes/helpers.js +269 -0
  34. package/dist/tests/core/changes/list-fixtures.js +122 -0
  35. package/dist/tests/core/changes/list-long-history.test.js +52 -0
  36. package/dist/tests/core/changes/list-paging.test.js +511 -0
  37. package/dist/tests/core/changes/list-parse.test.js +120 -0
  38. package/dist/tests/core/changes/list-tree.test.js +44 -0
  39. package/dist/tests/core/changes/list.test.js +821 -0
  40. package/dist/tests/core/changes/parse.test.js +142 -0
  41. package/dist/tests/core/changes/predicates.test.js +83 -0
  42. package/dist/tests/core/changes/range.test.js +92 -0
  43. package/dist/tests/core/changes/receipt.test.js +947 -0
  44. package/dist/tests/core/changes/record-io.test.js +307 -0
  45. package/dist/tests/core/changes/record.test.js +549 -0
  46. package/dist/tests/core/changes/render-list.test.js +212 -0
  47. package/dist/tests/core/changes/render.test.js +202 -0
  48. package/dist/tests/core/changes/text-safety.test.js +226 -0
  49. package/dist/tests/core/changes/trailers.test.js +181 -0
  50. package/dist/tests/core/changes/worktree.test.js +259 -0
  51. package/dist/tests/release/orchestrator-trailer.test.js +72 -0
  52. package/dist/tests/release/orchestrator.test.js +1 -0
  53. package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
  54. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
  55. package/package.json +1 -1
  56. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  57. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  58. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  59. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,120 @@
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
+ exports.deriveFromCommits = deriveFromCommits;
7
+ exports.deriveModules = deriveModules;
8
+ exports.commitsFromRecords = commitsFromRecords;
9
+ exports.deriveFields = deriveFields;
10
+ const path_1 = __importDefault(require("path"));
11
+ const core_1 = require("../../release/core");
12
+ const git_1 = require("./git");
13
+ const text_safety_1 = require("./text-safety");
14
+ const types_1 = require("./types");
15
+ // Pathspecs relative to the repository top, so the result does not depend on the directory git runs in.
16
+ const ALL_FILES = ':/';
17
+ const EXCLUDE_RECORDS = `:(top,exclude,literal)${types_1.RECORD_DIR}`;
18
+ const PARSER_TYPE = /^\w+$/;
19
+ const RECORD_TYPE = /^[a-z]+$/;
20
+ const MODULE_DEPTH = 3;
21
+ // R3.3: the release parser's header regex backtracks quadratically on a long subject with blanks and line terminators, so a
22
+ // subject (the text before the first US of a record) over this many UTF-16 units is never handed to it.
23
+ const MAX_SUBJECT_UNITS = 1024;
24
+ /** Keeps the commits the release parser can produce (a `\w+` type, any spelling) with a scope that is usable text; anything else is ignored. */
25
+ function usable(commits) {
26
+ if (!Array.isArray(commits))
27
+ return [];
28
+ const out = [];
29
+ for (const c of commits) {
30
+ if (typeof c !== 'object' || c === null)
31
+ continue;
32
+ const { type, scope, breaking, subject } = c;
33
+ if (typeof type !== 'string' || !PARSER_TYPE.test(type))
34
+ continue;
35
+ const cleanScope = typeof scope === 'string' && scope.trim() !== '' && (0, text_safety_1.isSingleLine)(scope) ? scope.trim() : null;
36
+ out.push({ type, scope: cleanScope, breaking: breaking === true, subject: typeof subject === 'string' ? subject : '' });
37
+ }
38
+ return out;
39
+ }
40
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
41
+ /**
42
+ * R3.3. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics; the recorded type is
43
+ * the highest-impact commit's type lowercased, or `other` when that is still not purely letters. Null when no commit is usable.
44
+ */
45
+ function deriveFromCommits(input) {
46
+ const commits = usable(input);
47
+ if (commits.length === 0)
48
+ return null;
49
+ const breaking = commits.some((c) => c.breaking);
50
+ const pick = commits.find((c) => c.breaking) ?? commits.find((c) => c.type === 'feat') ?? commits.find((c) => c.type === 'fix')
51
+ ?? commits.find((c) => c.type === 'perf') ?? commits[0];
52
+ const counts = new Map();
53
+ for (const c of commits)
54
+ if (c.scope)
55
+ counts.set(c.scope, (counts.get(c.scope) ?? 0) + 1);
56
+ const scope = [...counts.entries()].sort((a, b) => b[1] - a[1] || byCodeUnit(a[0], b[0]))[0]?.[0] ?? null;
57
+ const semver = (0, core_1.determineBump)(commits) ?? 'none';
58
+ const type = pick.type.toLowerCase();
59
+ return { type: RECORD_TYPE.test(type) ? type : 'other', scope, semver, breaking };
60
+ }
61
+ /** R3.4. Directories a record cannot hold (control characters, backslashes, `..` ...) are left out rather than leaked into it. */
62
+ function deriveModules(files) {
63
+ if (!Array.isArray(files))
64
+ return [];
65
+ const modules = new Set();
66
+ for (const f of files) {
67
+ if (typeof f !== 'string' || f.length === 0 || f.startsWith(`${types_1.RECORD_DIR}/`))
68
+ continue;
69
+ const dir = path_1.default.posix.dirname(f);
70
+ const module = dir === '.' ? '.' : dir.split('/').slice(0, MODULE_DEPTH).join('/');
71
+ if (module === '.' || (0, text_safety_1.isSafeRelativePath)(module))
72
+ modules.add(module);
73
+ }
74
+ return [...modules].sort();
75
+ }
76
+ /**
77
+ * The conventional commits of NUL-separated `git log` records. Measured on the raw record, before RS is stripped (the stripped text
78
+ * is never longer): like the release parser, the subject ends at the first US, so only the text before it counts, and a subject over
79
+ * MAX_SUBJECT_UNITS is never handed to the parser.
80
+ */
81
+ function commitsFromRecords(records) {
82
+ return records.flatMap((record) => {
83
+ const end = record.indexOf(core_1.US);
84
+ return (end < 0 ? record.length : end) > MAX_SUBJECT_UNITS ? [] : (0, core_1.parseCommits)(record.split(core_1.RS).join(''));
85
+ });
86
+ }
87
+ const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, text_safety_1.clipMessage)(message) } });
88
+ /**
89
+ * Derives from the non-merge commits in mergeBaseSha..HEAD, ignoring commits and files that only touch docs/changes/.
90
+ * `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
91
+ */
92
+ function deriveFields(root, mergeBaseSha) {
93
+ if (typeof root !== 'string' || root.length === 0)
94
+ return fail('invalid-input', 'repository root must be a non-empty string');
95
+ if (typeof mergeBaseSha !== 'string' || !types_1.COMMIT_ID.test(mergeBaseSha))
96
+ return fail('invalid-input', `merge-base must be a full commit id, got "${(0, text_safety_1.esc)(mergeBaseSha)}"`);
97
+ // HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots.
98
+ const head = (0, git_1.headSha)(root);
99
+ if (head === null)
100
+ return fail('git-failed', 'git could not read HEAD');
101
+ const range = `${mergeBaseSha}..${head}`;
102
+ // --full-history: with a pathspec git would otherwise prune the side of a merge whose tree equals the other parent
103
+ // (`merge -s ours`), silently dropping real commits from the range.
104
+ // -z ends every commit with NUL, which a message cannot contain: each record is exactly one commit, so a message that
105
+ // embeds the parser's own separator cannot be read as a second commit.
106
+ const raw = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '--no-merges', '--full-history', '--reverse', '-z', `--format=${core_1.GIT_LOG_FORMAT}`, '--end-of-options', range, '--', ALL_FILES, EXCLUDE_RECORDS]);
107
+ if (raw === null)
108
+ return fail('git-failed', `git could not read the commits in ${range}`);
109
+ const records = raw.split('\0').filter((record) => record !== '');
110
+ if (records.length === 0)
111
+ return fail('no-commits', `no non-merge commits outside ${types_1.RECORD_DIR}/ in ${range}`);
112
+ const commits = commitsFromRecords(records);
113
+ const derived = deriveFromCommits(commits);
114
+ if (derived === null)
115
+ return fail('no-conventional-commits', 'no commit in the range uses a conventional-commit subject (type(scope): subject)');
116
+ const files = (0, git_1.changedFiles)(root, mergeBaseSha, head);
117
+ if (files === null)
118
+ return fail('git-failed', `git could not list the files changed in ${range}`);
119
+ return { ok: true, fields: { ...derived, modules: deriveModules(files) } };
120
+ }
@@ -0,0 +1,47 @@
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
+ exports.isRegularFileInside = isRegularFileInside;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const text_safety_1 = require("./text-safety");
10
+ // Read-only filesystem checks. No import of the write-capable record-io: the pure record validator depends on this module.
11
+ /** True when `name` is listed in `parent` exactly as spelled: on a case-insensitive file system `stat` also finds other spellings. */
12
+ function listedAs(parent, name) {
13
+ try {
14
+ return fs_1.default.readdirSync(parent).includes(name);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * A repo-relative path naming a regular file (not a link) that lies inside `root` once every link on the way is resolved, and
22
+ * whose every segment is spelled exactly as on disk (so the answer is the same on a case-insensitive file system as on Linux,
23
+ * where the validation of the committed record will run). Never throws.
24
+ */
25
+ function isRegularFileInside(root, rel) {
26
+ if (typeof root !== 'string' || !(0, text_safety_1.isSafeRelativePath)(rel))
27
+ return false;
28
+ const segments = rel.split('/');
29
+ const abs = path_1.default.join(root, ...segments);
30
+ try {
31
+ if (!fs_1.default.lstatSync(abs).isFile())
32
+ return false;
33
+ const inside = path_1.default.relative(fs_1.default.realpathSync.native(root), fs_1.default.realpathSync.native(abs));
34
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path_1.default.sep}`) || path_1.default.isAbsolute(inside))
35
+ return false;
36
+ let parent = root;
37
+ for (const segment of segments) {
38
+ if (!listedAs(parent, segment))
39
+ return false;
40
+ parent = path_1.default.join(parent, segment);
41
+ }
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // Leaf module: how every git child of the change-record code is spawned. No sibling imports.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.GIT_TIMEOUT_MS = void 0;
5
+ exports.gitChildEnv = gitChildEnv;
6
+ exports.gitSpawnOptions = gitSpawnOptions;
7
+ exports.execGit = execGit;
8
+ exports.execGitBytes = execGitBytes;
9
+ const child_process_1 = require("child_process");
10
+ exports.GIT_TIMEOUT_MS = 15000;
11
+ /**
12
+ * Variables that change how pathspecs are read (`:/`, `:(exclude)` ...) and so the meaning of every range/count/status we ask
13
+ * for: literal/glob/noglob/icase would turn a plain exclusion into a literal name, a glob or a case-insensitive match. They are
14
+ * removed from the child's environment, matched case-insensitively (Windows environment names are case-insensitive, and a
15
+ * plain-object copy of process.env is not); everything else (GIT_CONFIG_*, GIT_AUTHOR_*, HOME ...) is passed through untouched.
16
+ */
17
+ const PATHSPEC_ENV = new Set(['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS']);
18
+ /**
19
+ * Variables that relocate the repository, its index or its object store: inherited ones would make git answer for a different
20
+ * repository than the `cwd` we pass (a fail-open). The same set read-only-observation.ts removes for its own git children.
21
+ */
22
+ const LOCATION_ENV = new Set([
23
+ 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
24
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_PREFIX',
25
+ ]);
26
+ /**
27
+ * Makes git fail on a missing object (partial clone) instead of fetching it from the promisor remote. Needs git 2.43 or newer:
28
+ * an older git ignores the variable and keeps its previous behaviour (it fetches the object), so the read-only guarantee for
29
+ * partial clones is only given from 2.43 on.
30
+ */
31
+ const NO_LAZY_FETCH = 'GIT_NO_LAZY_FETCH';
32
+ /** The environment every git child of the change-record code runs in: pathspec and repository-location variables removed, lazy fetching off. */
33
+ function gitChildEnv() {
34
+ const env = { ...process.env };
35
+ for (const name of Object.keys(env))
36
+ if (PATHSPEC_ENV.has(name.toUpperCase()) || LOCATION_ENV.has(name.toUpperCase()))
37
+ delete env[name];
38
+ // A partial clone would otherwise fetch a missing object from its promisor remote the moment a read needs it: that writes
39
+ // the object database and uses the network. Reading must stay read-only, so a missing object is a plain failure instead.
40
+ // (Windows environment names are case-insensitive: drop any other spelling first.)
41
+ for (const name of Object.keys(env))
42
+ if (name.toUpperCase() === NO_LAZY_FETCH)
43
+ delete env[name];
44
+ env[NO_LAZY_FETCH] = '1';
45
+ return env;
46
+ }
47
+ /** The options of every git child except `stdio` (execGit spells that out): no shell, utf8, bounded time, scrubbed environment. */
48
+ function gitSpawnOptions(cwd, extras = {}) {
49
+ return {
50
+ cwd,
51
+ encoding: 'utf8',
52
+ timeout: exports.GIT_TIMEOUT_MS,
53
+ env: gitChildEnv(),
54
+ ...(extras.maxBuffer === undefined ? {} : { maxBuffer: extras.maxBuffer }),
55
+ };
56
+ }
57
+ /**
58
+ * Runs `git args` in `cwd` with gitSpawnOptions and returns its stdout. The one execFileSync of the change-record code, with the
59
+ * stdio option spelled out at the call (stdin closed, stdout piped, stderr as asked; never Node's default relay of the child's
60
+ * stderr onto ours), as tests/structural/exec-invocation-explicit-stdio.test.ts requires. Throws like execFileSync does.
61
+ */
62
+ function execGit(cwd, args, extras = {}) {
63
+ return (0, child_process_1.execFileSync)('git', args, { ...gitSpawnOptions(cwd, extras), stdio: ['ignore', 'pipe', extras.stderr ?? 'pipe'] });
64
+ }
65
+ /** Like execGit, but returns the raw bytes of stdout (no decoding): for content that must be checked as UTF-8 strictly. */
66
+ function execGitBytes(cwd, args, extras = {}) {
67
+ return (0, child_process_1.execFileSync)('git', args, { ...gitSpawnOptions(cwd, extras), encoding: 'buffer', stdio: ['ignore', 'pipe', extras.stderr ?? 'pipe'] });
68
+ }
@@ -0,0 +1,216 @@
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
+ exports.LOG_SAFETY_ARGS = void 0;
7
+ exports.runGit = runGit;
8
+ exports.runGitBytes = runGitBytes;
9
+ exports.isSafeRef = isSafeRef;
10
+ exports.isSafeRevision = isSafeRevision;
11
+ exports.resolveBaseCommit = resolveBaseCommit;
12
+ exports.mergeBase = mergeBase;
13
+ exports.isAncestor = isAncestor;
14
+ exports.changedFiles = changedFiles;
15
+ exports.addedFilesUnder = addedFilesUnder;
16
+ exports.isCommittedClean = isCommittedClean;
17
+ exports.isDirtyOutside = isDirtyOutside;
18
+ exports.repoRoot = repoRoot;
19
+ exports.commitSha = commitSha;
20
+ exports.headSha = headSha;
21
+ exports.trimSlashes = trimSlashes;
22
+ exports.identityFromUrl = identityFromUrl;
23
+ exports.repoIdentity = repoIdentity;
24
+ const path_1 = __importDefault(require("path"));
25
+ const git_env_1 = require("./git-env");
26
+ const text_safety_1 = require("./text-safety");
27
+ const types_1 = require("./types");
28
+ const worktree_1 = require("./worktree");
29
+ // Every function here takes values that may be influenced by a user or by repository content (refs, paths, remote URLs).
30
+ // None of them throws: git failures and invalid input come back as null / false / true (fail closed) so that callers
31
+ // decide how to report them. Git runs without a shell, and a revision never reaches it unless it passed a whitelist.
32
+ const MAX_OUTPUT = 16 * 1024 * 1024;
33
+ const SAFE_REF = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/;
34
+ const MAX_REF = 255;
35
+ const MAX_IDENTITY = 300;
36
+ const MAX_REMOTE_URL = 2048;
37
+ /**
38
+ * Every `git log` whose output is parsed must start with these. User and repository config would otherwise change that
39
+ * output: `log.showSignature` prints verification text before each signed commit's formatted output, and
40
+ * `i18n.logOutputEncoding` re-encodes it. (`log.decorate`, `log.mailmap`, notes and colour only affect placeholders or
41
+ * default formats we do not use.)
42
+ */
43
+ exports.LOG_SAFETY_ARGS = ['--no-show-signature', '--encoding=UTF-8'];
44
+ /** Runs git without a shell and returns its stdout, or null when it fails, times out, overflows or the input is invalid. */
45
+ function runGit(cwd, args) {
46
+ if (typeof cwd !== 'string' || cwd.length === 0 || cwd.includes('\0'))
47
+ return null;
48
+ if (!Array.isArray(args) || !args.every((a) => typeof a === 'string' && !a.includes('\0')))
49
+ return null;
50
+ try {
51
+ return (0, git_env_1.execGit)(cwd, args, { maxBuffer: MAX_OUTPUT });
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Like runGit, but returns the raw bytes of stdout (for content that must be decoded strictly); null on the same failures. */
58
+ function runGitBytes(cwd, args) {
59
+ if (typeof cwd !== 'string' || cwd.length === 0 || cwd.includes('\0'))
60
+ return null;
61
+ if (!Array.isArray(args) || !args.every((a) => typeof a === 'string' && !a.includes('\0')))
62
+ return null;
63
+ try {
64
+ return (0, git_env_1.execGitBytes)(cwd, args, { maxBuffer: MAX_OUTPUT });
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** A ref name that cannot be read as an option, a range, a revision expression or a path. */
71
+ function isSafeRef(ref) {
72
+ return typeof ref === 'string' && ref.length <= MAX_REF && SAFE_REF.test(ref) && !ref.includes('..');
73
+ }
74
+ /** A safe ref, or `a..b` / `a...b` made of two safe refs. */
75
+ function isSafeRevision(rev) {
76
+ if (typeof rev !== 'string')
77
+ return false;
78
+ if (rev.length > 2 * MAX_REF + 3)
79
+ return false;
80
+ const parts = rev.split(/\.\.\.?/);
81
+ return parts.length <= 2 && parts.every(isSafeRef);
82
+ }
83
+ /**
84
+ * R3.1: --base, then origin/HEAD, then main, then master: the first that names a commit, with that commit's id. Null when none
85
+ * does (or the explicit base is unsafe). One `rev-parse` per candidate.
86
+ */
87
+ function resolveBaseCommit(root, explicit) {
88
+ for (const candidate of explicit !== undefined ? [explicit] : ['refs/remotes/origin/HEAD', 'main', 'master']) {
89
+ const sha = commitSha(root, candidate);
90
+ if (sha !== null)
91
+ return { base: candidate, sha };
92
+ }
93
+ return null;
94
+ }
95
+ /** Full id of the common ancestor of `base` and HEAD; null when there is none or the input is invalid. */
96
+ function mergeBase(root, base) {
97
+ if (!isSafeRef(base))
98
+ return null;
99
+ const out = runGit(root, ['merge-base', '--end-of-options', base, 'HEAD'])?.trim();
100
+ return out !== undefined && types_1.COMMIT_ID.test(out) ? out : null;
101
+ }
102
+ function isAncestor(root, ancestor, descendant) {
103
+ if (!isSafeRef(ancestor) || !isSafeRef(descendant))
104
+ return false;
105
+ return runGit(root, ['merge-base', '--is-ancestor', '--end-of-options', ancestor, descendant]) !== null;
106
+ }
107
+ /** Paths differing between two revisions, renames reported as delete + add, verbatim (untrusted). Null on failure. */
108
+ function changedFiles(root, from, to = 'HEAD') {
109
+ if (!isSafeRef(from) || !isSafeRef(to))
110
+ return null;
111
+ // --no-renames: diff.renames would report a rename as one path. -c diff.relative=false: diff.relative would make paths relative to
112
+ // the cwd and drop those outside it (the config form, unlike --no-relative, also works on git < 2.28). --ignore-submodules=none: diff.ignoreSubmodules / .gitmodules `ignore` would hide a
113
+ // submodule bump. -z: core.quotePath never applies. diff.noprefix and colour do not affect --name-only.
114
+ const out = runGit(root, ['-c', 'diff.relative=false', 'diff', '--no-renames', '--ignore-submodules=none', '--name-only', '-z', '--end-of-options', from, to, '--']);
115
+ return out === null ? null : out.split('\0').filter(Boolean);
116
+ }
117
+ /**
118
+ * Files ADDED (not modified, not renamed-into: renames are off) between two revisions under the repo-relative directory `dir`,
119
+ * verbatim (untrusted). Same safety flags as changedFiles. Null on failure or invalid input.
120
+ */
121
+ function addedFilesUnder(root, from, to, dir) {
122
+ if (!isSafeRef(from) || !isSafeRef(to) || !(0, text_safety_1.isSafeRelativePath)(dir))
123
+ return null;
124
+ const out = runGit(root, ['-c', 'diff.relative=false', 'diff', '--no-renames', '--ignore-submodules=none', '--diff-filter=A', '--name-only', '-z', '--end-of-options', from, to, '--', `:(top,literal)${dir}`]);
125
+ return out === null ? null : out.split('\0').filter(Boolean);
126
+ }
127
+ /**
128
+ * True when the tracked file `rel` (repo-relative, matched literally, from any directory) equals HEAD in both the index and the
129
+ * working tree; false when it differs; null when that cannot be told (unsafe path, git failure). Untracked files are not looked at.
130
+ * `--no-optional-locks`: `status` would otherwise refresh the index, and a read-only caller must not write it.
131
+ */
132
+ function isCommittedClean(root, rel) {
133
+ if (!(0, text_safety_1.isSafeRelativePath)(rel))
134
+ return null;
135
+ const out = runGit(root, ['--no-optional-locks', 'status', '--porcelain', '-z', '--untracked-files=no', '--ignore-submodules=none', '--', `:(top,literal)${rel}`]);
136
+ return out === null ? null : out.length === 0;
137
+ }
138
+ /**
139
+ * Whether the tree differs from HEAD outside RUNTIME_STATE_EXCLUDES and `extraExcludes` (untracked files count).
140
+ * Unknown state, or an unsafe exclusion, counts as dirty: an unreadable repository must never look clean.
141
+ */
142
+ function isDirtyOutside(root, extraExcludes) {
143
+ const state = (0, worktree_1.gitState)(root, true, extraExcludes);
144
+ return state.kind !== 'ok' || state.dirty;
145
+ }
146
+ /** Repository root with native separators; null outside a repository. Only `rev-parse`: never a (slow, refreshing) `status`. */
147
+ function repoRoot(cwd) {
148
+ const out = runGit(cwd, ['rev-parse', '--show-toplevel'])?.trim();
149
+ return out ? path_1.default.resolve(out) : null;
150
+ }
151
+ /** Full id of the commit a safe ref names; null for an unsafe ref, no such commit or a git failure. Only `rev-parse`. */
152
+ function commitSha(root, ref) {
153
+ if (!isSafeRef(ref))
154
+ return null;
155
+ const out = runGit(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`])?.trim();
156
+ return out !== undefined && types_1.COMMIT_ID.test(out) ? out : null;
157
+ }
158
+ /** Full id of HEAD; null when there is no commit or git fails. Only `rev-parse`: never a `status`. */
159
+ function headSha(root) {
160
+ return commitSha(root, 'HEAD');
161
+ }
162
+ /** Removes any run of `/` from the start and the end of `s` (index scan: linear, unlike /\/+$/). */
163
+ function trimSlashes(s) {
164
+ let start = 0;
165
+ let end = s.length;
166
+ while (start < end && s[start] === '/')
167
+ start++;
168
+ while (end > start && s[end - 1] === '/')
169
+ end--;
170
+ return s.slice(start, end);
171
+ }
172
+ /** `name` without one trailing `.git`. */
173
+ const withoutDotGit = (s) => (s.endsWith('.git') ? s.slice(0, -4) : s);
174
+ /** `text` up to the first `?` or `#`: a query or fragment of a remote can carry a token, and is not part of the repository name. */
175
+ function withoutQueryAndFragment(text) {
176
+ const cut = text.search(/[?#]/);
177
+ return cut < 0 ? text : text.slice(0, cut);
178
+ }
179
+ /** `user@host:path` (scp-style); null when the text is not of that form. The host is lowercased like in the URL form. */
180
+ function scpIdentity(url) {
181
+ const at = url.indexOf('@');
182
+ if (at < 1 || url.slice(0, at).includes('/'))
183
+ return null;
184
+ const colon = url.indexOf(':', at + 1);
185
+ if (colon < 0)
186
+ return null;
187
+ const host = url.slice(at + 1, colon);
188
+ // A `?`, `#` or `@` in the host segment can only be a token or a malformed remote: nothing of it is ever echoed.
189
+ if (host === '' || /[/?#@]/.test(host))
190
+ return null;
191
+ const repo = withoutDotGit(trimSlashes(withoutQueryAndFragment(url.slice(colon + 1))));
192
+ return repo === '' ? null : `${host.toLowerCase()}/${repo}`;
193
+ }
194
+ /** `host/owner/name` from a remote url with credentials, query and fragment stripped; null when unparseable, too long or not single-line. */
195
+ function identityFromUrl(rawUrl) {
196
+ const url = typeof rawUrl === 'string' ? rawUrl.trim() : '';
197
+ if (!url || url.length > MAX_REMOTE_URL)
198
+ return null;
199
+ let identity = scpIdentity(url);
200
+ if (identity === null) {
201
+ try {
202
+ const u = new URL(url);
203
+ const p = withoutDotGit(trimSlashes(u.pathname));
204
+ identity = u.hostname && p ? `${u.hostname}/${p}` : null;
205
+ }
206
+ catch {
207
+ identity = null;
208
+ }
209
+ }
210
+ return identity !== null && (0, text_safety_1.isSingleLine)(identity) && identity.length <= MAX_IDENTITY ? identity : null;
211
+ }
212
+ /** `host/owner/name` from remote.origin.url (see identityFromUrl); null when there is no remote. */
213
+ function repoIdentity(root) {
214
+ const url = runGit(root, ['config', '--get', 'remote.origin.url']);
215
+ return url === null ? null : identityFromUrl(url);
216
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cleanLine = cleanLine;
4
+ exports.parseSince = parseSince;
5
+ exports.parsePr = parsePr;
6
+ const text_safety_1 = require("./text-safety");
7
+ // Pure text and number parsing of `awm changes list`: no git, no I/O, nothing throws. Shared by the reader (list.ts), the
8
+ // renderings (render-list.ts) and the command.
9
+ /**
10
+ * Untrusted text as one clean line of at most `max` UTF-16 units: controls, line breaks and bidi marks escaped, an ellipsis
11
+ * where it was cut, a surrogate pair never split. Never throws.
12
+ */
13
+ function cleanLine(text, max) {
14
+ if (typeof text !== 'string')
15
+ return text_safety_1.NON_STRING;
16
+ const raw = (0, text_safety_1.sliceNoSplit)(text, max);
17
+ let out = (0, text_safety_1.cleanText)(raw);
18
+ if (raw.length < text.length || out.length > max)
19
+ out = `${out.length > max - 1 ? (0, text_safety_1.sliceNoSplit)(out, max - 1) : out}…`;
20
+ return out;
21
+ }
22
+ const RELATIVE = /^(\d{1,9})([hd])$/;
23
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
24
+ const MAX_SINCE_TEXT = 16;
25
+ /** `<n>h` or `<n>d` (n of 1 to 9 digits, at least 1, the result a valid date) or a calendar date `YYYY-MM-DD`; null for anything else. Never throws. */
26
+ function parseSince(value, now) {
27
+ if (typeof value !== 'string' || value.length > MAX_SINCE_TEXT || !(now instanceof Date))
28
+ return null;
29
+ const nowMs = now.getTime();
30
+ if (!Number.isFinite(nowMs))
31
+ return null;
32
+ const rel = RELATIVE.exec(value);
33
+ if (rel) {
34
+ const n = Number(rel[1]);
35
+ const hours = rel[2] === 'h';
36
+ if (n < 1)
37
+ return null;
38
+ const since = new Date(nowMs - n * (hours ? 3600e3 : 86400e3));
39
+ return Number.isNaN(since.getTime()) ? null : since;
40
+ }
41
+ if (ISO_DATE.test(value)) {
42
+ const since = new Date(`${value}T00:00:00Z`);
43
+ return !Number.isNaN(since.getTime()) && since.toISOString().slice(0, 10) === value ? since : null;
44
+ }
45
+ return null;
46
+ }
47
+ /** The number of a trailing `(#<n>)`; null when there is none or n is not a safe integer. Index scans only. */
48
+ function parsePr(subject) {
49
+ if (typeof subject !== 'string')
50
+ return null;
51
+ let end = subject.length;
52
+ while (end > 0 && (subject[end - 1] === ' ' || subject[end - 1] === '\t' || subject[end - 1] === '\r' || subject[end - 1] === '\n'))
53
+ end--;
54
+ if (end < 4 || subject[end - 1] !== ')')
55
+ return null;
56
+ const open = subject.lastIndexOf('(#', end - 1);
57
+ if (open < 0)
58
+ return null;
59
+ const digits = subject.slice(open + 2, end - 1);
60
+ if (digits.length === 0 || digits.length > 16)
61
+ return null;
62
+ for (let i = 0; i < digits.length; i++)
63
+ if (digits[i] < '0' || digits[i] > '9')
64
+ return null;
65
+ const n = Number(digits);
66
+ return Number.isSafeInteger(n) ? n : null;
67
+ }