@procedures/git 0.0.0-stage → 0.2.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 (120) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1130 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/index.d.ts +20 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +20 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/module.d.ts +22 -0
  10. package/dist/module.d.ts.map +1 -0
  11. package/dist/module.js +514 -0
  12. package/dist/module.js.map +1 -0
  13. package/dist/procedures/git/add.d.ts +12 -0
  14. package/dist/procedures/git/add.d.ts.map +1 -0
  15. package/dist/procedures/git/add.js +26 -0
  16. package/dist/procedures/git/add.js.map +1 -0
  17. package/dist/procedures/git/args.d.ts +15 -0
  18. package/dist/procedures/git/args.d.ts.map +1 -0
  19. package/dist/procedures/git/args.js +20 -0
  20. package/dist/procedures/git/args.js.map +1 -0
  21. package/dist/procedures/git/branch.d.ts +12 -0
  22. package/dist/procedures/git/branch.d.ts.map +1 -0
  23. package/dist/procedures/git/branch.js +54 -0
  24. package/dist/procedures/git/branch.js.map +1 -0
  25. package/dist/procedures/git/checkout.d.ts +12 -0
  26. package/dist/procedures/git/checkout.d.ts.map +1 -0
  27. package/dist/procedures/git/checkout.js +23 -0
  28. package/dist/procedures/git/checkout.js.map +1 -0
  29. package/dist/procedures/git/clone.d.ts +12 -0
  30. package/dist/procedures/git/clone.d.ts.map +1 -0
  31. package/dist/procedures/git/clone.js +28 -0
  32. package/dist/procedures/git/clone.js.map +1 -0
  33. package/dist/procedures/git/commit.d.ts +12 -0
  34. package/dist/procedures/git/commit.d.ts.map +1 -0
  35. package/dist/procedures/git/commit.js +41 -0
  36. package/dist/procedures/git/commit.js.map +1 -0
  37. package/dist/procedures/git/diff.d.ts +18 -0
  38. package/dist/procedures/git/diff.d.ts.map +1 -0
  39. package/dist/procedures/git/diff.js +58 -0
  40. package/dist/procedures/git/diff.js.map +1 -0
  41. package/dist/procedures/git/fetch.d.ts +12 -0
  42. package/dist/procedures/git/fetch.d.ts.map +1 -0
  43. package/dist/procedures/git/fetch.js +29 -0
  44. package/dist/procedures/git/fetch.js.map +1 -0
  45. package/dist/procedures/git/index.d.ts +15 -0
  46. package/dist/procedures/git/index.d.ts.map +1 -0
  47. package/dist/procedures/git/index.js +15 -0
  48. package/dist/procedures/git/index.js.map +1 -0
  49. package/dist/procedures/git/init.d.ts +12 -0
  50. package/dist/procedures/git/init.d.ts.map +1 -0
  51. package/dist/procedures/git/init.js +32 -0
  52. package/dist/procedures/git/init.js.map +1 -0
  53. package/dist/procedures/git/log.d.ts +12 -0
  54. package/dist/procedures/git/log.d.ts.map +1 -0
  55. package/dist/procedures/git/log.js +35 -0
  56. package/dist/procedures/git/log.js.map +1 -0
  57. package/dist/procedures/git/predicates.d.ts +40 -0
  58. package/dist/procedures/git/predicates.d.ts.map +1 -0
  59. package/dist/procedures/git/predicates.js +76 -0
  60. package/dist/procedures/git/predicates.js.map +1 -0
  61. package/dist/procedures/git/pull.d.ts +12 -0
  62. package/dist/procedures/git/pull.d.ts.map +1 -0
  63. package/dist/procedures/git/pull.js +36 -0
  64. package/dist/procedures/git/pull.js.map +1 -0
  65. package/dist/procedures/git/push.d.ts +12 -0
  66. package/dist/procedures/git/push.d.ts.map +1 -0
  67. package/dist/procedures/git/push.js +39 -0
  68. package/dist/procedures/git/push.js.map +1 -0
  69. package/dist/procedures/git/remote.d.ts +13 -0
  70. package/dist/procedures/git/remote.d.ts.map +1 -0
  71. package/dist/procedures/git/remote.js +30 -0
  72. package/dist/procedures/git/remote.js.map +1 -0
  73. package/dist/procedures/git/run.d.ts +32 -0
  74. package/dist/procedures/git/run.d.ts.map +1 -0
  75. package/dist/procedures/git/run.js +60 -0
  76. package/dist/procedures/git/run.js.map +1 -0
  77. package/dist/procedures/git/stash.d.ts +38 -0
  78. package/dist/procedures/git/stash.d.ts.map +1 -0
  79. package/dist/procedures/git/stash.js +211 -0
  80. package/dist/procedures/git/stash.js.map +1 -0
  81. package/dist/procedures/git/status.d.ts +17 -0
  82. package/dist/procedures/git/status.d.ts.map +1 -0
  83. package/dist/procedures/git/status.js +107 -0
  84. package/dist/procedures/git/status.js.map +1 -0
  85. package/dist/procedures/git.d.ts +5 -0
  86. package/dist/procedures/git.d.ts.map +1 -0
  87. package/dist/procedures/git.js +5 -0
  88. package/dist/procedures/git.js.map +1 -0
  89. package/dist/register.d.ts +7 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +9 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/types.d.ts +350 -0
  94. package/dist/types.d.ts.map +1 -0
  95. package/dist/types.js +397 -0
  96. package/dist/types.js.map +1 -0
  97. package/package.json +77 -3
  98. package/src/index.ts +20 -0
  99. package/src/module.ts +656 -0
  100. package/src/procedures/git/add.ts +29 -0
  101. package/src/procedures/git/args.ts +21 -0
  102. package/src/procedures/git/branch.ts +61 -0
  103. package/src/procedures/git/checkout.ts +27 -0
  104. package/src/procedures/git/clone.ts +33 -0
  105. package/src/procedures/git/commit.ts +46 -0
  106. package/src/procedures/git/diff.ts +62 -0
  107. package/src/procedures/git/fetch.ts +32 -0
  108. package/src/procedures/git/index.ts +22 -0
  109. package/src/procedures/git/init.ts +43 -0
  110. package/src/procedures/git/log.ts +40 -0
  111. package/src/procedures/git/predicates.ts +90 -0
  112. package/src/procedures/git/pull.ts +45 -0
  113. package/src/procedures/git/push.ts +42 -0
  114. package/src/procedures/git/remote.ts +31 -0
  115. package/src/procedures/git/run.ts +70 -0
  116. package/src/procedures/git/stash.ts +255 -0
  117. package/src/procedures/git/status.ts +98 -0
  118. package/src/procedures/git.ts +5 -0
  119. package/src/register.ts +11 -0
  120. package/src/types.ts +815 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * git.branch procedure
3
+ *
4
+ * Branch operations
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, type GitContext } from "./run.js";
9
+ import type { GitBranchInput, GitBranchOutput, GitBranchInfo } from "../../types.js";
10
+
11
+ /**
12
+ * Branch operations
13
+ */
14
+ export async function gitBranch(input: GitBranchInput, ctx: GitContext = {}): Promise<GitBranchOutput> {
15
+ const { name, delete: del, list, remote, cwd } = input;
16
+ const run = { cwd, signal: ctx.signal };
17
+
18
+ // Get current branch
19
+ const current = (await git(["rev-parse", "--abbrev-ref", "HEAD"], run)).trim();
20
+
21
+ // Delete branch
22
+ if (del && name) {
23
+ await git(["branch", "-d", gitArg("name", name)], run);
24
+ return { deleted: name, current };
25
+ }
26
+
27
+ // Create branch
28
+ if (name && !list) {
29
+ await git(["branch", gitArg("name", name)], run);
30
+ return { created: name, current };
31
+ }
32
+
33
+ // List branches
34
+ const args = ["branch"];
35
+ if (remote) args.push("-a");
36
+ // The full ref name tells local from remote branches: a local "feature/x" also contains "/"
37
+ args.push("--format=%(refname)|%(refname:short)|%(HEAD)|%(upstream:short)");
38
+
39
+ const output = await git(args, run);
40
+ const branches: GitBranchInfo[] = output
41
+ .split("\n")
42
+ .filter(Boolean)
43
+ .map(line => {
44
+ const parts = line.split("|");
45
+ const fullName = parts[0] ?? "";
46
+ const branchName = parts[1] ?? "";
47
+ const head = parts[2] ?? "";
48
+ const trackingVal = parts[3];
49
+ const result: GitBranchInfo = {
50
+ name: branchName.replace(/^remotes\//, ""),
51
+ current: head === "*",
52
+ remote: fullName.startsWith("refs/remotes/"),
53
+ };
54
+ if (trackingVal) {
55
+ result.tracking = trackingVal;
56
+ }
57
+ return result;
58
+ });
59
+
60
+ return { branches, current };
61
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * git.checkout procedure
3
+ *
4
+ * Checkout branch or files
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, type GitContext } from "./run.js";
9
+ import type { GitCheckoutInput, GitCheckoutOutput } from "../../types.js";
10
+
11
+ /**
12
+ * Checkout branch or files
13
+ */
14
+ export async function gitCheckout(input: GitCheckoutInput, ctx: GitContext = {}): Promise<GitCheckoutOutput> {
15
+ const { ref, create, paths, cwd } = input;
16
+
17
+ const args: string[] = ["checkout"];
18
+ if (create) args.push("-b");
19
+ args.push(gitArg("ref", ref));
20
+ if (paths && paths.length > 0) {
21
+ args.push("--", ...paths);
22
+ }
23
+
24
+ await git(args, { cwd, signal: ctx.signal });
25
+
26
+ return { ref, created: create };
27
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * git.clone procedure
3
+ *
4
+ * Clone repository
5
+ */
6
+
7
+ import { join, basename } from "node:path";
8
+ import { gitArg } from "./args.js";
9
+ import { git, type GitContext } from "./run.js";
10
+ import type { GitCloneInput, GitCloneOutput } from "../../types.js";
11
+
12
+ /**
13
+ * Clone repository
14
+ */
15
+ export async function gitClone(input: GitCloneInput, ctx: GitContext = {}): Promise<GitCloneOutput> {
16
+ const { url, dest, branch, depth, cwd } = input;
17
+
18
+ // Determine destination directory
19
+ const destDir = dest || basename(url, ".git").replace(/\.git$/, "");
20
+ const fullPath = cwd ? join(cwd, destDir) : destDir;
21
+
22
+ const args: string[] = ["clone"];
23
+ if (branch) args.push("-b", branch);
24
+ if (depth) args.push("--depth", String(depth));
25
+ args.push("--", gitArg("url", url), gitArg("dest", destDir));
26
+
27
+ await git(args, { cwd, signal: ctx.signal });
28
+
29
+ // Get the branch that was checked out
30
+ const clonedBranch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: fullPath, signal: ctx.signal })).trim();
31
+
32
+ return { path: fullPath, branch: clonedBranch };
33
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * git.commit procedure
3
+ *
4
+ * Create commit
5
+ */
6
+
7
+ import { git, type GitContext } from "./run.js";
8
+ import type { GitCommitInput, GitCommitOutput } from "../../types.js";
9
+
10
+ /**
11
+ * Create commit
12
+ */
13
+ export async function gitCommit(input: GitCommitInput, ctx: GitContext = {}): Promise<GitCommitOutput> {
14
+ const { message, all, amend, cwd } = input;
15
+ const run = { cwd, signal: ctx.signal };
16
+
17
+ // Check if there's anything to commit (unless amending)
18
+ if (!amend) {
19
+ const status = (await git(["status", "--porcelain"], run)).trim();
20
+ if (!status && !all) {
21
+ // Nothing staged and not using -a, skip commit
22
+ return { hash: "", message: "", author: "", date: "", skipped: true };
23
+ }
24
+ // If using -a, check if there are any modified files
25
+ if (all) {
26
+ const hasChanges = status.length > 0;
27
+ if (!hasChanges) {
28
+ return { hash: "", message: "", author: "", date: "", skipped: true };
29
+ }
30
+ }
31
+ }
32
+
33
+ const args: string[] = ["commit"];
34
+ if (all) args.push("-a");
35
+ if (amend) args.push("--amend");
36
+ args.push("-m", message);
37
+
38
+ await git(args, run);
39
+
40
+ // Get commit info
41
+ const format = "--format=%H%n%s%n%an%n%ci";
42
+ const info = (await git(["log", "-1", format], run)).trim();
43
+ const [hash = "", msg = "", author = "", date = ""] = info.split("\n");
44
+
45
+ return { hash, message: msg, author, date };
46
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * git.diff procedure
3
+ *
4
+ * Show changes
5
+ *
6
+ * The counts come from `git diff --numstat -z`. With `-z`, each path is exact, and a rename gives
7
+ * the old and the new path in separate fields (deep dive WRP-7). Before, the procedure split each
8
+ * line at whitespace: a path with a space lost its end, and a rename gave its old path only.
9
+ */
10
+
11
+ import { gitArg } from "./args.js";
12
+ import { git, zFields, type GitContext } from "./run.js";
13
+ import type { GitDiffInput, GitDiffOutput, GitDiffFile } from "../../types.js";
14
+
15
+ /** This function reads the output of `git diff --numstat -z`. */
16
+ export function parseNumstat(output: string): GitDiffFile[] {
17
+ const files: GitDiffFile[] = [];
18
+ const fields = zFields(output);
19
+ for (let i = 0; i < fields.length; i++) {
20
+ const [add = "0", del = "0", ...rest] = fields[i]!.split("\t");
21
+ // A binary file has "-" for both counts
22
+ const additions = add === "-" ? 0 : parseInt(add, 10);
23
+ const deletions = del === "-" ? 0 : parseInt(del, 10);
24
+ const path = rest.join("\t");
25
+ if (path === "") {
26
+ // A rename or a copy: the old path, then the new path, in the next two fields
27
+ const from = fields[++i] ?? "";
28
+ const to = fields[++i] ?? "";
29
+ files.push({ path: to, from, additions, deletions });
30
+ } else {
31
+ files.push({ path, additions, deletions });
32
+ }
33
+ }
34
+ return files;
35
+ }
36
+
37
+ /**
38
+ * Show changes
39
+ */
40
+ export async function gitDiff(input: GitDiffInput, ctx: GitContext = {}): Promise<GitDiffOutput> {
41
+ const { staged, ref, paths, stat, cwd } = input;
42
+ const run = { cwd, signal: ctx.signal };
43
+
44
+ const selection: string[] = [];
45
+ if (staged) selection.push("--cached");
46
+ if (ref) selection.push(gitArg("ref", ref));
47
+ const pathspec = paths && paths.length > 0 ? ["--", ...paths] : [];
48
+
49
+ const files = parseNumstat(await git(["diff", ...selection, "--numstat", "-z", ...pathspec], run));
50
+ const result: GitDiffOutput = {
51
+ files,
52
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
53
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
54
+ };
55
+
56
+ // Include full diff if not stat-only
57
+ if (!stat) {
58
+ result.diff = await git(["diff", ...selection, ...pathspec], run);
59
+ }
60
+
61
+ return result;
62
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * git.fetch procedure
3
+ *
4
+ * Fetch from remote without merging
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, type GitContext } from "./run.js";
9
+ import type { GitFetchInput, GitFetchOutput } from "../../types.js";
10
+
11
+ /**
12
+ * Fetch from remote
13
+ */
14
+ export async function gitFetch(input: GitFetchInput, ctx: GitContext = {}): Promise<GitFetchOutput> {
15
+ const { branch, all, prune, cwd } = input;
16
+ const remoteName = input.remote ?? "origin";
17
+
18
+ const args: string[] = ["fetch"];
19
+ if (all) {
20
+ args.push("--all");
21
+ } else {
22
+ args.push(gitArg("remote", remoteName));
23
+ if (branch) {
24
+ args.push(gitArg("branch", branch));
25
+ }
26
+ }
27
+ if (prune) args.push("--prune");
28
+
29
+ await git(args, { cwd, signal: ctx.signal });
30
+
31
+ return { remote: remoteName, fetched: true };
32
+ }
@@ -0,0 +1,22 @@
1
+ export { gitStatus } from "./status.js";
2
+ export { gitAdd } from "./add.js";
3
+ export { gitCommit } from "./commit.js";
4
+ export { gitPush } from "./push.js";
5
+ export { gitPull } from "./pull.js";
6
+ export { gitClone } from "./clone.js";
7
+ export { gitCheckout } from "./checkout.js";
8
+ export { gitBranch } from "./branch.js";
9
+ export { gitLog } from "./log.js";
10
+ export { gitDiff } from "./diff.js";
11
+ export { gitInit } from "./init.js";
12
+ export { gitRemote, gitRemoteSet } from "./remote.js";
13
+ export { gitFetch } from "./fetch.js";
14
+ export {
15
+ gitStashList,
16
+ gitStashPush,
17
+ gitStashPop,
18
+ gitStashApply,
19
+ gitStashDrop,
20
+ gitStashExport,
21
+ gitStashImport,
22
+ } from "./stash.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * git.init procedure
3
+ *
4
+ * Initialize a git repository
5
+ */
6
+
7
+ import { join, resolve } from "node:path";
8
+ import type { ProcedureContext } from "@procedures/client";
9
+ import { git } from "./run.js";
10
+ import type { GitInitInput, GitInitOutput } from "../../types.js";
11
+
12
+ interface FsExistsOutput { exists: boolean; path: string; }
13
+
14
+ /**
15
+ * Initialize a git repository
16
+ */
17
+ export async function gitInit(input: GitInitInput, ctx: ProcedureContext): Promise<GitInitOutput> {
18
+ const cwd = input.cwd ? resolve(input.cwd) : process.cwd();
19
+
20
+ // Check if already a git repo
21
+ const gitDir = join(cwd, ".git");
22
+ const existsResult = await ctx.client.call<{ path: string }, FsExistsOutput>(
23
+ ["fs", "exists"],
24
+ { path: gitDir }
25
+ );
26
+ const alreadyExists = existsResult.exists;
27
+
28
+ if (!alreadyExists) {
29
+ const args: string[] = ["init"];
30
+ if (input.bare) {
31
+ args.push("--bare");
32
+ }
33
+ if (input.initialBranch) {
34
+ args.push(`--initial-branch=${input.initialBranch}`);
35
+ }
36
+ await git(args, { cwd, signal: ctx.signal });
37
+ }
38
+
39
+ return {
40
+ path: cwd,
41
+ created: !alreadyExists,
42
+ };
43
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * git.log procedure
3
+ *
4
+ * Show commit log
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, type GitContext } from "./run.js";
9
+ import type { GitLogInput, GitLogOutput, GitLogCommit } from "../../types.js";
10
+
11
+ // Control characters that do not occur in a name, an email, a date or a subject
12
+ const FIELD = "\x1f";
13
+ const RECORD = "\x1e";
14
+
15
+ /**
16
+ * Show commit log
17
+ */
18
+ export async function gitLog(input: GitLogInput, ctx: GitContext = {}): Promise<GitLogOutput> {
19
+ const { count, ref, cwd } = input;
20
+
21
+ const format = `--format=${["%H", "%h", "%an", "%ae", "%ci", "%s"].join("%x1f")}%x1e`;
22
+ const args = ["log", `-n${count}`, format];
23
+ if (ref) args.push(gitArg("ref", ref));
24
+
25
+ const output = await git(args, { cwd, signal: ctx.signal });
26
+ const commits: GitLogCommit[] = output
27
+ .split(RECORD)
28
+ .map((record) => record.replace(/^\n/, ""))
29
+ .filter((record) => record.length > 0)
30
+ .map((record) => {
31
+ const [hash = "", shortHash = "", author = "", email = "", date = "", message = ""] = record.split(FIELD);
32
+ return { hash, shortHash, author, email, date, message };
33
+ });
34
+
35
+ const result: GitLogOutput = { commits };
36
+ if (input.oneline) {
37
+ result.lines = commits.map((commit) => `${commit.shortHash} ${commit.message}`);
38
+ }
39
+ return result;
40
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Git predicate procedures
3
+ *
4
+ * Simple boolean predicates for conditional execution.
5
+ * These return true/false based on git repository state.
6
+ */
7
+
8
+ import { git, GitError, type GitContext } from "./run.js";
9
+
10
+ export interface GitPredicateInput {
11
+ /** Working directory (default: process.cwd()) */
12
+ cwd?: string | undefined;
13
+ }
14
+
15
+ export interface GitPredicateOutput {
16
+ /** The predicate result */
17
+ value: boolean;
18
+ }
19
+
20
+ /**
21
+ * The output of a git command, or null when git fails (for example outside a repository).
22
+ * An aborted signal still throws.
23
+ */
24
+ async function gitOrNull(args: string[], cwd: string | undefined, ctx: GitContext): Promise<string | null> {
25
+ try {
26
+ return (await git(args, { cwd, signal: ctx.signal })).trim();
27
+ } catch (error) {
28
+ if (error instanceof GitError) return null;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Check if there are any changes (unstaged, staged, or untracked)
35
+ */
36
+ export async function gitHasChanges(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
37
+ const status = await gitOrNull(["status", "--porcelain"], input.cwd, ctx);
38
+ return { value: status !== null && status.length > 0 };
39
+ }
40
+
41
+ /**
42
+ * Check if there are any staged changes ready to commit
43
+ */
44
+ export async function gitHasStagedChanges(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
45
+ // git diff --cached shows only staged changes
46
+ const diff = await gitOrNull(["diff", "--cached", "--name-only"], input.cwd, ctx);
47
+ return { value: diff !== null && diff.length > 0 };
48
+ }
49
+
50
+ /**
51
+ * Check if there are any unstaged changes (modified or deleted files)
52
+ */
53
+ export async function gitHasUnstagedChanges(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
54
+ // git diff shows only unstaged changes to tracked files
55
+ const diff = await gitOrNull(["diff", "--name-only"], input.cwd, ctx);
56
+ return { value: diff !== null && diff.length > 0 };
57
+ }
58
+
59
+ /**
60
+ * Check if there are any untracked files
61
+ */
62
+ export async function gitHasUntrackedFiles(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
63
+ // List untracked files only
64
+ const untracked = await gitOrNull(["ls-files", "--others", "--exclude-standard"], input.cwd, ctx);
65
+ return { value: untracked !== null && untracked.length > 0 };
66
+ }
67
+
68
+ /**
69
+ * Check if there are local commits that haven't been pushed
70
+ */
71
+ export async function gitHasLocalCommits(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
72
+ // Get the upstream tracking branch
73
+ const upstream = await gitOrNull(["rev-parse", "--abbrev-ref", "@{upstream}"], input.cwd, ctx);
74
+ if (upstream !== null) {
75
+ // Count commits ahead of upstream
76
+ const count = await gitOrNull(["rev-list", "--count", `${upstream}..HEAD`], input.cwd, ctx);
77
+ if (count !== null) return { value: parseInt(count, 10) > 0 };
78
+ }
79
+ // No upstream or error - HEAD exists but no upstream: treat as having local commits
80
+ const head = await gitOrNull(["rev-parse", "HEAD"], input.cwd, ctx);
81
+ return { value: head !== null };
82
+ }
83
+
84
+ /**
85
+ * Check if the working directory is clean (no changes at all)
86
+ */
87
+ export async function gitIsClean(input: GitPredicateInput, ctx: GitContext = {}): Promise<GitPredicateOutput> {
88
+ const result = await gitHasChanges(input, ctx);
89
+ return { value: !result.value };
90
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * git.pull procedure
3
+ *
4
+ * Pull from remote
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, type GitContext } from "./run.js";
9
+ import type { GitPullInput, GitPullOutput } from "../../types.js";
10
+
11
+ /**
12
+ * Pull from remote
13
+ */
14
+ export async function gitPull(input: GitPullInput, ctx: GitContext = {}): Promise<GitPullOutput> {
15
+ const { branch, rebase, cwd } = input;
16
+ const remoteName = input.remote ?? "origin";
17
+ const run = { cwd, signal: ctx.signal };
18
+
19
+ // Get current branch if not specified
20
+ const branchName = branch || (await git(["rev-parse", "--abbrev-ref", "HEAD"], run)).trim();
21
+
22
+ // Get current HEAD before pull
23
+ const beforeHead = (await git(["rev-parse", "HEAD"], run)).trim();
24
+
25
+ const args: string[] = ["pull"];
26
+ if (rebase) args.push("--rebase");
27
+ args.push(gitArg("remote", remoteName), gitArg("branch", branchName));
28
+
29
+ await git(args, run);
30
+
31
+ // Get new HEAD after pull
32
+ const afterHead = (await git(["rev-parse", "HEAD"], run)).trim();
33
+
34
+ // Count new commits
35
+ let commits = 0;
36
+ if (beforeHead !== afterHead) {
37
+ const count = (await git(["rev-list", "--count", `${beforeHead}..${afterHead}`], run)).trim();
38
+ commits = parseInt(count, 10) || 0;
39
+ }
40
+
41
+ // Check if it was fast-forward
42
+ const fastForward = !rebase && beforeHead !== afterHead;
43
+
44
+ return { remote: remoteName, branch: branchName, commits, fastForward };
45
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * git.push procedure
3
+ *
4
+ * Push to remote
5
+ */
6
+
7
+ import { gitArg } from "./args.js";
8
+ import { git, GitError, type GitContext } from "./run.js";
9
+ import type { GitPushInput, GitPushOutput } from "../../types.js";
10
+
11
+ /**
12
+ * Push to remote
13
+ */
14
+ export async function gitPush(input: GitPushInput, ctx: GitContext = {}): Promise<GitPushOutput> {
15
+ const { branch, force, setUpstream, cwd } = input;
16
+ const remoteName = input.remote ?? "origin";
17
+ const run = { cwd, signal: ctx.signal };
18
+
19
+ // Get current branch if not specified
20
+ const branchName = branch || (await git(["rev-parse", "--abbrev-ref", "HEAD"], run)).trim();
21
+
22
+ // Count commits to push
23
+ let commits = 0;
24
+ try {
25
+ const count = (await git(["rev-list", "--count", `${remoteName}/${branchName}..HEAD`], run)).trim();
26
+ commits = parseInt(count, 10) || 0;
27
+ } catch (error) {
28
+ // Remote branch may not exist yet
29
+ if (!(error instanceof GitError)) throw error;
30
+ const count = (await git(["rev-list", "--count", "HEAD"], run)).trim();
31
+ commits = parseInt(count, 10) || 0;
32
+ }
33
+
34
+ const args: string[] = ["push"];
35
+ if (setUpstream) args.push("-u");
36
+ if (force) args.push("--force");
37
+ args.push(gitArg("remote", remoteName), gitArg("branch", branchName));
38
+
39
+ await git(args, run);
40
+
41
+ return { remote: remoteName, branch: branchName, commits };
42
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * git.remote and git.remote.set
3
+ *
4
+ * git.remote reads the URL of a remote. git.remote.set replaces it, or adds the remote. Two
5
+ * procedures, so that a reader of the URL declares that it changes nothing (CAPABILITIES-2026-10).
6
+ */
7
+
8
+ import { gitArg } from "./args.js";
9
+ import { git, GitError, type GitContext } from "./run.js";
10
+ import type { GitRemoteInput, GitRemoteOutput, GitRemoteSetInput } from "../../types.js";
11
+
12
+ /** This function gives the URL of a remote. */
13
+ export async function gitRemote(input: GitRemoteInput, ctx: GitContext = {}): Promise<GitRemoteOutput> {
14
+ const { name, cwd } = input;
15
+ const url = (await git(["remote", "get-url", gitArg("name", name)], { cwd, signal: ctx.signal })).trim();
16
+ return { name, url };
17
+ }
18
+
19
+ /** This function sets the URL of a remote, and adds the remote when it is missing. */
20
+ export async function gitRemoteSet(input: GitRemoteSetInput, ctx: GitContext = {}): Promise<GitRemoteOutput> {
21
+ const { name, url, cwd } = input;
22
+ const run = { cwd, signal: ctx.signal };
23
+ try {
24
+ await git(["remote", "set-url", gitArg("name", name), gitArg("url", url)], run);
25
+ } catch (error) {
26
+ // The remote is missing: add it
27
+ if (!(error instanceof GitError)) throw error;
28
+ await git(["remote", "add", gitArg("name", name), gitArg("url", url)], run);
29
+ }
30
+ return { name, url };
31
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * How the git procedures run git: through `runCommand` of client-shell (roadmap 2.2).
3
+ *
4
+ * Before, they called `execFileSync`: each git call blocked the event loop of the host (an MCP
5
+ * server or a warm `mark` server stopped all its other calls), and the signal of the call could
6
+ * not stop git. Now each call is async, the signal kills git, and the output has a limit.
7
+ */
8
+
9
+ import { runCommand } from "@procedures/shell/command";
10
+ import { GIT_MAX_BUFFER } from "./args.js";
11
+
12
+ /** The part of the procedure context that the git procedures read. */
13
+ export interface GitContext {
14
+ signal?: AbortSignal | undefined;
15
+ }
16
+
17
+ export interface GitRunOptions extends GitContext {
18
+ cwd?: string | undefined;
19
+ /** Text to write to the stdin of git (for example a patch for `git apply`). */
20
+ input?: string | undefined;
21
+ }
22
+
23
+ /** A git command that ended with an exit code other than 0. */
24
+ export class GitError extends Error {
25
+ constructor(
26
+ readonly args: readonly string[],
27
+ readonly exitCode: number,
28
+ readonly stdout: string,
29
+ readonly stderr: string,
30
+ ) {
31
+ super(`git ${args.join(" ")} failed (exit ${exitCode}): ${(stderr || stdout).trim()}`);
32
+ this.name = "GitError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * This function runs `git <args>` and gives its stdout. It throws a `GitError` when git fails,
38
+ * and an `AbortError` when the signal aborts.
39
+ */
40
+ export async function git(args: string[], options: GitRunOptions = {}): Promise<string> {
41
+ const result = await runCommand("git", {
42
+ args,
43
+ cwd: options.cwd,
44
+ signal: options.signal,
45
+ input: options.input,
46
+ maxOutputBytes: GIT_MAX_BUFFER,
47
+ });
48
+ if (result.aborted) {
49
+ const error = new Error(`git ${args[0]} was aborted`);
50
+ error.name = "AbortError";
51
+ throw error;
52
+ }
53
+ if (result.error) {
54
+ throw new Error(`git did not start: ${result.error}`);
55
+ }
56
+ if (result.truncated?.stdout) {
57
+ throw new Error(`The output of git ${args[0]} is longer than ${GIT_MAX_BUFFER} bytes`);
58
+ }
59
+ if (!result.success) {
60
+ throw new GitError(args, result.exitCode, result.stdout, result.stderr);
61
+ }
62
+ return result.stdout;
63
+ }
64
+
65
+ /** This function splits output that git wrote with `-z` into its fields. */
66
+ export function zFields(output: string): string[] {
67
+ const fields = output.split("\0");
68
+ if (fields.at(-1) === "") fields.pop();
69
+ return fields;
70
+ }