@samaksh/git-worker 0.1.0 → 0.2.1

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 (47) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/services/git/reader.js +105 -34
  5. package/dist/services/workspace/manager.js +1 -1
  6. package/dist/stages/eligibility.js +38 -14
  7. package/dist/stages/select.js +46 -37
  8. package/package.json +11 -1
  9. package/GIT_WORKER_PROJECT_PLAN.md +0 -499
  10. package/demo/README.md +0 -58
  11. package/demo/index.ts +0 -526
  12. package/src/app.ts +0 -69
  13. package/src/cli/commands.ts +0 -142
  14. package/src/cli/index.ts +0 -123
  15. package/src/config/defaults.ts +0 -46
  16. package/src/config/load.ts +0 -70
  17. package/src/config/schema.ts +0 -48
  18. package/src/core/errors.ts +0 -45
  19. package/src/core/pipeline.ts +0 -17
  20. package/src/core/types.ts +0 -188
  21. package/src/services/editor/index.ts +0 -85
  22. package/src/services/git/ops.ts +0 -170
  23. package/src/services/git/reader.ts +0 -148
  24. package/src/services/report/index.ts +0 -104
  25. package/src/services/review/index.ts +0 -80
  26. package/src/services/state/index.ts +0 -77
  27. package/src/services/template/env.ts +0 -169
  28. package/src/services/template/interpolator.ts +0 -109
  29. package/src/services/template/transform.ts +0 -97
  30. package/src/services/template/validator.ts +0 -75
  31. package/src/services/workspace/cache.ts +0 -144
  32. package/src/services/workspace/manager.ts +0 -370
  33. package/src/stages/apply.ts +0 -194
  34. package/src/stages/edit-template.ts +0 -227
  35. package/src/stages/eligibility.ts +0 -67
  36. package/src/stages/load-template.ts +0 -188
  37. package/src/stages/plan.ts +0 -362
  38. package/src/stages/push.ts +0 -120
  39. package/src/stages/report.ts +0 -126
  40. package/src/stages/resolve-project.ts +0 -341
  41. package/src/stages/review.ts +0 -228
  42. package/src/stages/select.ts +0 -302
  43. package/src/stages/sync.ts +0 -63
  44. package/src/utils/hash.ts +0 -15
  45. package/src/utils/ui.ts +0 -137
  46. package/src/utils/which.ts +0 -18
  47. package/tsconfig.json +0 -18
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ const program = new Command();
7
7
  program
8
8
  .name("git-worker")
9
9
  .description("Safe, composable CLI/TUI for applying reviewed file changes across many Git branches")
10
- .version("0.1.0")
10
+ .version("0.2.1")
11
11
  .option("-r, --repo <path>", "Repository path or remote URL (default: current directory)")
12
12
  .option("-c, --cached", "Select from previously cloned and cached repositories")
13
13
  .option("-b, --branch <pattern>", "Branch pattern — glob (uat/*) or regex (/uat.*/)")
@@ -0,0 +1,3 @@
1
+ export * from "./app.js";
2
+ export * from "./core/types.js";
3
+ export * from "./core/errors.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./app.js";
2
+ export * from "./core/types.js";
3
+ export * from "./core/errors.js";
@@ -23,56 +23,127 @@ export async function readFileFromBranch(repoPath, branch, filePath) {
23
23
  export async function listBranches(repoPath, remote) {
24
24
  try {
25
25
  const git = simpleGit(repoPath);
26
- const branches = [];
27
- const seen = new Set();
28
- // Remote branches (source of truth for managed clone)
26
+ // Fast path: use git for-each-ref plumbing command.
27
+ // Retrieves all branch names, SHAs, and ref types in a single process execution (<25ms for 300+ branches)
28
+ // instead of running hundreds of sequential rev-parse child processes.
29
29
  try {
30
- const remoteBranches = await git.branch(["-r"]);
31
- for (const fullName of remoteBranches.all) {
32
- if (fullName.includes("HEAD"))
30
+ const rawOutput = await git.raw([
31
+ "for-each-ref",
32
+ "--sort=-committerdate",
33
+ "--format=%(objectname)\t%(refname)",
34
+ "refs/remotes",
35
+ "refs/heads",
36
+ ]);
37
+ const remoteBranches = [];
38
+ const localBranches = [];
39
+ for (const rawLine of rawOutput.split("\n")) {
40
+ const line = rawLine.trim();
41
+ if (!line)
33
42
  continue;
34
- const parts = fullName.trim().split("/");
35
- if (parts.length < 2)
43
+ const tabIdx = line.indexOf("\t");
44
+ if (tabIdx === -1)
36
45
  continue;
37
- const remoteName = parts[0];
38
- const branchName = parts.slice(1).join("/");
39
- if (remote && remoteName !== remote)
46
+ const sha = line.substring(0, tabIdx).trim();
47
+ const refname = line.substring(tabIdx + 1).trim();
48
+ if (!sha || !refname)
40
49
  continue;
41
- if (seen.has(branchName))
42
- continue;
43
- try {
44
- const sha = await git.revparse([fullName.trim()]);
45
- seen.add(branchName);
46
- branches.push({
50
+ if (refname.startsWith("refs/remotes/")) {
51
+ const rest = refname.substring("refs/remotes/".length);
52
+ const slashIdx = rest.indexOf("/");
53
+ if (slashIdx === -1)
54
+ continue;
55
+ const remoteName = rest.substring(0, slashIdx);
56
+ const branchName = rest.substring(slashIdx + 1);
57
+ // Skip symbolic refs like origin/HEAD
58
+ if (branchName === "HEAD" || branchName.endsWith("/HEAD"))
59
+ continue;
60
+ if (remote && remoteName !== remote)
61
+ continue;
62
+ remoteBranches.push({
47
63
  name: branchName,
48
64
  remote: remoteName,
49
- sha: sha.trim(),
65
+ sha,
50
66
  isRemote: true,
51
67
  });
52
68
  }
53
- catch {
54
- // skip branches we can't resolve
69
+ else if (refname.startsWith("refs/heads/")) {
70
+ const branchName = refname.substring("refs/heads/".length);
71
+ localBranches.push({
72
+ name: branchName,
73
+ sha,
74
+ isRemote: false,
75
+ });
55
76
  }
56
77
  }
57
- }
58
- catch {
59
- // No remotes is ok
60
- }
61
- // Local branches (only if not already discovered on remote)
62
- try {
63
- const local = await git.branchLocal();
64
- for (const name of local.all) {
65
- if (!seen.has(name)) {
66
- const sha = await git.revparse([name]);
67
- seen.add(name);
68
- branches.push({ name, sha: sha.trim(), isRemote: false });
78
+ const branches = [];
79
+ const seen = new Set();
80
+ // 1. Remote branches take priority (source of truth for managed clone)
81
+ for (const b of remoteBranches) {
82
+ if (!seen.has(b.name)) {
83
+ seen.add(b.name);
84
+ branches.push(b);
69
85
  }
70
86
  }
87
+ // 2. Local branches (only if not already discovered on remote)
88
+ for (const b of localBranches) {
89
+ if (!seen.has(b.name)) {
90
+ seen.add(b.name);
91
+ branches.push(b);
92
+ }
93
+ }
94
+ return ok(branches);
71
95
  }
72
96
  catch {
73
- // ignore
97
+ // Fallback path: use standard branch enumeration if for-each-ref fails
98
+ const branches = [];
99
+ const seen = new Set();
100
+ try {
101
+ const remoteBranches = await git.branch(["-r"]);
102
+ for (const fullName of remoteBranches.all) {
103
+ if (fullName.includes("HEAD"))
104
+ continue;
105
+ const parts = fullName.trim().split("/");
106
+ if (parts.length < 2)
107
+ continue;
108
+ const remoteName = parts[0];
109
+ const branchName = parts.slice(1).join("/");
110
+ if (remote && remoteName !== remote)
111
+ continue;
112
+ if (seen.has(branchName))
113
+ continue;
114
+ try {
115
+ const sha = await git.revparse([fullName.trim()]);
116
+ seen.add(branchName);
117
+ branches.push({
118
+ name: branchName,
119
+ remote: remoteName,
120
+ sha: sha.trim(),
121
+ isRemote: true,
122
+ });
123
+ }
124
+ catch {
125
+ // skip unresolvable
126
+ }
127
+ }
128
+ }
129
+ catch {
130
+ // no remotes
131
+ }
132
+ try {
133
+ const local = await git.branchLocal();
134
+ for (const name of local.all) {
135
+ if (!seen.has(name)) {
136
+ const sha = await git.revparse([name]);
137
+ seen.add(name);
138
+ branches.push({ name, sha: sha.trim(), isRemote: false });
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ // ignore
144
+ }
145
+ return ok(branches);
74
146
  }
75
- return ok(branches);
76
147
  }
77
148
  catch (e) {
78
149
  return err(gitError("Failed to list branches", e));
@@ -6,7 +6,7 @@ import { join, resolve, normalize } from "path";
6
6
  import { simpleGit, CheckRepoActions } from "simple-git";
7
7
  import { homedir } from "os";
8
8
  import { createHash } from "crypto";
9
- const GIT_WORKER_VERSION = "0.1.0";
9
+ const GIT_WORKER_VERSION = "0.2.1";
10
10
  const LOCK_FILE = ".git-worker-lock";
11
11
  /**
12
12
  * Extract a human-readable repository name from a remote URL or file path.
@@ -9,24 +9,48 @@ export const checkEligibilityStage = async (context, deps) => {
9
9
  const eligible = [];
10
10
  const ineligible = [];
11
11
  const isEnvTarget = context.target.path === ".env" || context.target.path.startsWith(".env.");
12
- for (const branch of context.selectedBranches) {
13
- // 1. Check if the target file exists in the branch
14
- const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, context.target.path);
15
- const targetFound = targetRead.ok;
16
- // 2. If target is a non-.env file, check if .env is available as variable source
17
- let envNote = "";
18
- if (!isEnvTarget) {
19
- const envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
20
- if (envRead.ok) {
21
- envNote = " • .env present";
12
+ const total = context.selectedBranches.length;
13
+ const isBatchMode = total > 8;
14
+ const s = clack.spinner();
15
+ if (isBatchMode) {
16
+ s.start(`Checking eligibility for ${total} branches…`);
17
+ }
18
+ // Concurrent execution pool (up to 16 workers in parallel)
19
+ const concurrency = Math.min(16, total);
20
+ let completed = 0;
21
+ let currentIndex = 0;
22
+ const workers = Array.from({ length: concurrency }, async () => {
23
+ while (currentIndex < total) {
24
+ const idx = currentIndex++;
25
+ const branch = context.selectedBranches[idx];
26
+ // 1. Check if the target file exists in the branch
27
+ const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, context.target.path);
28
+ const targetFound = targetRead.ok;
29
+ // 2. If target is a non-.env file, check if .env is available as variable source
30
+ let envNote = "";
31
+ if (!isEnvTarget) {
32
+ const envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
33
+ if (envRead.ok) {
34
+ envNote = " • .env present";
35
+ }
36
+ else {
37
+ envNote = " • .env missing (variables will require fallbacks)";
38
+ }
39
+ }
40
+ const fileStatus = targetFound ? "target file found" : "target file will be created";
41
+ eligible.push(branch);
42
+ completed++;
43
+ if (isBatchMode) {
44
+ s.message(`Checking eligibility (${completed}/${total})…`);
22
45
  }
23
46
  else {
24
- envNote = " • .env missing (variables will require fallbacks)";
47
+ statusLine(targetFound ? "ok" : "info", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
25
48
  }
26
49
  }
27
- const fileStatus = targetFound ? "target file found" : "target file will be created";
28
- eligible.push(branch);
29
- statusLine(targetFound ? "ok" : "info", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
50
+ });
51
+ await Promise.all(workers);
52
+ if (isBatchMode) {
53
+ s.stop(`Checked ${total} branches (${eligible.length} eligible)`);
30
54
  }
31
55
  if (ineligible.length > 0) {
32
56
  console.log("");
@@ -1,6 +1,7 @@
1
1
  import { ok, err } from "../core/types.js";
2
2
  import { workspaceError, inputCancelledError } from "../core/errors.js";
3
- import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
3
+ import { clack, sectionHeader, statusLine } from "../utils/ui.js";
4
+ import inquirer from "inquirer";
4
5
  import chalk from "chalk";
5
6
  /**
6
7
  * Matches a branch name against a pattern.
@@ -100,44 +101,43 @@ export const selectTargetsStage = async (context, deps) => {
100
101
  hint: "proceed to target file selection",
101
102
  });
102
103
  options.push({
103
- value: "invert",
104
- label: `🔄 Invert selection (select the other ${totalCount - selectedCount} branches)`,
105
- hint: "flip current selection",
104
+ value: "list",
105
+ label: "📋 Select from branch list",
106
+ hint: "Space: toggle • a: select all • i: invert",
106
107
  });
107
108
  options.push({
108
- value: "pattern",
109
- label: "🔍 Filter by pattern or regex",
110
- hint: "glob (uat/*) or regex (/uat.*/)",
109
+ value: "all",
110
+ label: `🌟 Select all (a) — all ${totalCount} branches`,
111
111
  });
112
112
  options.push({
113
- value: "list",
114
- label: "📋 Customize selection from list",
115
- hint: "toggle individual checkboxes",
113
+ value: "invert",
114
+ label: `🔄 Invert selection (i) — select other ${totalCount - selectedCount} branch(es)`,
116
115
  });
117
116
  options.push({
118
- value: "all",
119
- label: `🌟 Select ALL branches (${totalCount} branches)`,
117
+ value: "pattern",
118
+ label: "🔍 Filter by pattern or regex",
119
+ hint: "glob (uat/*) or regex (/uat.*/)",
120
120
  });
121
121
  }
122
122
  else {
123
123
  options.push({
124
- value: "pattern",
125
- label: "🔍 Filter by pattern or regex",
126
- hint: "glob (uat/*) or regex (/uat.*/)",
124
+ value: "list",
125
+ label: "📋 Select from branch list",
126
+ hint: "Space: toggle • a: select all • i: invert",
127
127
  });
128
128
  options.push({
129
129
  value: "all",
130
- label: `🌟 Select ALL branches (${totalCount} branches)`,
130
+ label: `🌟 Select all (a) — all ${totalCount} branches`,
131
131
  hint: "select every branch in repository",
132
132
  });
133
133
  options.push({
134
- value: "list",
135
- label: "📋 Choose individually from list",
136
- hint: "select branches with checkboxes",
134
+ value: "invert",
135
+ label: `🔄 Invert selection (i) — select all ${totalCount} branches`,
137
136
  });
138
137
  options.push({
139
- value: "invert",
140
- label: `🔄 Invert selection (select all ${totalCount} branches)`,
138
+ value: "pattern",
139
+ label: "🔍 Filter by pattern or regex",
140
+ hint: "glob (uat/*) or regex (/uat.*/)",
141
141
  });
142
142
  }
143
143
  const previewText = selectedCount > 0
@@ -199,24 +199,33 @@ export const selectTargetsStage = async (context, deps) => {
199
199
  continue;
200
200
  }
201
201
  if (choice === "list") {
202
- const picked = await clack.multiselect({
203
- message: "Select branches to process (Space to toggle, Enter to confirm):",
204
- options: allBranches.map((b) => ({
205
- value: b.name,
206
- label: `${sym.branch} ${b.name}`,
207
- hint: b.isRemote
208
- ? `remote/${b.remote} ${b.sha.slice(0, 8)}`
209
- : `local ${b.sha.slice(0, 8)}`,
210
- })),
211
- initialValues: Array.from(selectedNames),
212
- required: false,
213
- });
214
- if (clack.isCancel(picked)) {
202
+ try {
203
+ const { picked } = await inquirer.prompt([
204
+ {
205
+ type: "checkbox",
206
+ name: "picked",
207
+ message: "Select branches to process:",
208
+ pageSize: 15,
209
+ choices: allBranches.map((b) => ({
210
+ name: `${b.name} (${b.isRemote ? `remote/${b.remote}` : "local"} ${b.sha.slice(0, 8)})`,
211
+ value: b.name,
212
+ checked: selectedNames.has(b.name),
213
+ })),
214
+ },
215
+ ]);
216
+ selectedNames = new Set(picked);
217
+ statusLine("ok", `Selection updated: ${selectedNames.size} branch(es) selected`);
218
+ continue;
219
+ }
220
+ catch (e) {
221
+ if (e.isTtyError ||
222
+ e.name === "ExitPromptError" ||
223
+ e.message?.includes("force closed") ||
224
+ e.message?.includes("User force closed")) {
225
+ return err(inputCancelledError("Branch selection cancelled"));
226
+ }
215
227
  continue;
216
228
  }
217
- selectedNames = new Set(picked);
218
- statusLine("ok", `Selection updated: ${selectedNames.size} branch(es) selected`);
219
- continue;
220
229
  }
221
230
  }
222
231
  branches = allBranches.filter((b) => selectedNames.has(b.name));
package/package.json CHANGED
@@ -1,15 +1,25 @@
1
1
  {
2
2
  "name": "@samaksh/git-worker",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Safe, composable TypeScript CLI/TUI for applying reviewed file changes across many Git branches",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
7
8
  "bin": {
8
9
  "git-worker": "dist/cli/index.js",
9
10
  "gitworker": "dist/cli/index.js"
10
11
  },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "docs"
19
+ ],
11
20
  "scripts": {
12
21
  "build": "tsc",
22
+ "prepublishOnly": "npm run build",
13
23
  "dev": "tsx src/cli/index.ts",
14
24
  "demo": "tsx demo/index.ts",
15
25
  "demo:ci": "tsx demo/index.ts --ci",