@ahmedalbarghouti/farq 0.0.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/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +74 -0
  5. package/dist/extract-json.d.ts +5 -0
  6. package/dist/extract-json.js +60 -0
  7. package/dist/git.d.ts +36 -0
  8. package/dist/git.js +277 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +204 -0
  11. package/dist/open-pr.d.ts +26 -0
  12. package/dist/open-pr.js +199 -0
  13. package/dist/providers/claude.d.ts +3 -0
  14. package/dist/providers/claude.js +88 -0
  15. package/dist/providers/fake.d.ts +8 -0
  16. package/dist/providers/fake.js +43 -0
  17. package/dist/providers/index.d.ts +22 -0
  18. package/dist/providers/index.js +40 -0
  19. package/dist/providers/opencode.d.ts +3 -0
  20. package/dist/providers/opencode.js +33 -0
  21. package/dist/render/json.d.ts +2 -0
  22. package/dist/render/json.js +3 -0
  23. package/dist/render/pr.d.ts +7 -0
  24. package/dist/render/pr.js +48 -0
  25. package/dist/render/slack.d.ts +2 -0
  26. package/dist/render/slack.js +25 -0
  27. package/dist/schema.d.ts +74 -0
  28. package/dist/schema.js +39 -0
  29. package/dist/summarize.d.ts +17 -0
  30. package/dist/summarize.js +72 -0
  31. package/dist/template.d.ts +11 -0
  32. package/dist/template.js +87 -0
  33. package/dist/title.d.ts +12 -0
  34. package/dist/title.js +37 -0
  35. package/dist/visual/chrome.d.ts +12 -0
  36. package/dist/visual/chrome.js +62 -0
  37. package/dist/visual/compose.d.ts +16 -0
  38. package/dist/visual/compose.js +58 -0
  39. package/dist/visual/diagram.d.ts +17 -0
  40. package/dist/visual/diagram.js +36 -0
  41. package/dist/visual/gate.d.ts +11 -0
  42. package/dist/visual/gate.js +28 -0
  43. package/dist/visual/mockup.d.ts +26 -0
  44. package/dist/visual/mockup.js +46 -0
  45. package/dist/visual/pipeline.d.ts +23 -0
  46. package/dist/visual/pipeline.js +126 -0
  47. package/package.json +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmed Albarghouti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # farq
2
+
3
+ **farq** (فرق, "difference") turns a git branch's changes into a paste-ready update: an AI-written summary plus an optional before/after visual.
4
+
5
+ One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
6
+
7
+ ```bash
8
+ npx @ahmedalbarghouti/farq@0.0.1 pr # title + markdown body (+ image when feasible)
9
+ npx farq slack # Slack mrkdwn daily update
10
+ npx farq json # structured JSON
11
+ npx farq pr --open # fill PR template + create with gh
12
+ ```
13
+
14
+ > **0.0.1** — first public release. Expect rough edges; the CLI surface (`pr` / `slack` / `json`) is the stable bit.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm i -g @ahmedalbarghouti/farq
20
+ farq --help
21
+ ```
22
+
23
+ Or run without installing:
24
+
25
+ ```bash
26
+ npx @ahmedalbarghouti/farq@0.0.1 --help
27
+ ```
28
+
29
+ Add `.farq/` to your repo ignore file (generated HTML/PNG land there).
30
+
31
+ ## Requirements
32
+
33
+ Checked at runtime when needed — nothing native is bundled.
34
+
35
+ | Tool | When needed |
36
+ |------|-------------|
37
+ | **Node ≥ 20** | always |
38
+ | **git** | always |
39
+ | **`claude` or `opencode`** | summarizing / generating visuals (`--provider fake` for dry runs) |
40
+ | **Google Chrome / Chromium** | image generation or `--before`/`--after` compose (`CHROME_PATH` override OK) |
41
+ | **`gh`** | only for `farq pr --open` |
42
+
43
+ Missing a required tool → one-line fix hint + non-zero exit. Optional visuals soft-degrade (see below).
44
+
45
+ ## Quick start
46
+
47
+ ```bash
48
+ # On a feature branch with commits (or dirty worktree):
49
+ farq pr --no-images # text only
50
+ farq pr # text + visual attempt
51
+ farq slack # paste into Slack
52
+ farq pr --open # create the GitHub PR
53
+ ```
54
+
55
+ Stdout is the artifact only (pipe-friendly):
56
+
57
+ ```bash
58
+ farq pr --no-images | pbcopy # macOS
59
+ farq pr --no-images | clip # Windows
60
+ ```
61
+
62
+ ## Commands
63
+
64
+ ### `farq pr` (default)
65
+
66
+ Line 1 = title. Blank line. Then markdown body (overview, optional before/after, changes, breaking).
67
+
68
+ ```bash
69
+ farq pr
70
+ farq pr --provider fake --no-images
71
+ farq pr --before shot-a.png --after shot-b.png
72
+ farq pr --open
73
+ ```
74
+
75
+ **`--open`** (feature branches only):
76
+
77
+ 1. Reads `.github` PR template if present and fills known sections
78
+ 2. Infers title style from recent merged PR titles when `gh` works
79
+ 3. Best-effort uploads the composed image as a prerelease asset and embeds the URL
80
+ 4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
81
+
82
+ On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
83
+
84
+ ### `farq slack`
85
+
86
+ Slack mrkdwn with category emoji. Images off by default.
87
+
88
+ ### `farq json`
89
+
90
+ Validated change summary plus an `images` array of produced file paths.
91
+
92
+ ## Images (honesty policy)
93
+
94
+ - Visuals come from the diff only. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. If the model cannot produce a faithful preview, **no image** is produced (still exit 0).
95
+ - Generated compositions include a small **generated preview** badge.
96
+ - Diagrams stay conceptual — no code dumps.
97
+ - Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
98
+ - A local `.farq/before-after.png` path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload.
99
+
100
+ ## Config
101
+
102
+ Precedence: **flags → project → global**.
103
+
104
+ - Project: `.farqrc` or `.farqrc.json`
105
+ - Global: `~/.config/farq/config.json`
106
+
107
+ ```json
108
+ {
109
+ "provider": "claude",
110
+ "tone": "technical",
111
+ "models": {
112
+ "claudeCheap": "haiku",
113
+ "opencodeCheap": "provider/model"
114
+ }
115
+ }
116
+ ```
117
+
118
+ If both `claude` and `opencode` are installed and nothing is configured, farq uses **claude** and prints how to override (no interactive prompt).
119
+
120
+ ## Flags
121
+
122
+ | Flag | Meaning |
123
+ |------|---------|
124
+ | `-r, --range` | git range (default: merge-base…HEAD, else worktree) |
125
+ | `-p, --provider` | `claude` \| `opencode` \| `fake` |
126
+ | `-t, --tone` | `technical` \| `client` |
127
+ | `--before` / `--after` | manual screenshots (skips generation) |
128
+ | `--no-images` | skip visuals |
129
+ | `-o, --out` | image output dir (default `.farq/`) |
130
+ | `--model-cheap` | cheap model id for visuals |
131
+ | `-v, --verbose` | verbose logs (incl. `feasible: false` reasons) |
132
+ | `--open` | (`pr` only) create PR with `gh` |
133
+
134
+ ## Exit codes
135
+
136
+ | Code | Meaning |
137
+ |------|---------|
138
+ | `0` | success (including no feasible image / skipped `--open` on default branch) |
139
+ | `1` | user/environment error |
140
+ | `2` | AI failure after retry / timeout |
141
+
142
+ Progress → **stderr**. Artifact → **stdout**.
143
+
144
+ ## Publishing notes (maintainers)
145
+
146
+ Tag-triggered publish via GitHub Actions (npm provenance):
147
+
148
+ ```bash
149
+ git tag v0.0.1
150
+ git push origin v0.0.1
151
+ ```
152
+
153
+ Requires repo secret `NPM_TOKEN`. Package name: `farq`.
154
+
155
+ ## Repository ops
156
+
157
+ - **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
158
+ - **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
159
+ - **Releases** are tag-triggered: create `v0.0.1` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
160
+
161
+ ## Roadmap
162
+
163
+ - Harder GitHub image upload (`user-attachments` URLs)
164
+ - Optional CDP snap of an already-running Chrome tab
165
+ - Richer multi-template chooser
166
+
167
+ ## License
168
+
169
+ MIT © Ahmed Albarghouti
@@ -0,0 +1,18 @@
1
+ export type ProviderName = "claude" | "opencode" | "fake";
2
+ export type ToneName = "technical" | "client";
3
+ export type FarqConfig = {
4
+ provider?: ProviderName;
5
+ tone?: ToneName;
6
+ models?: {
7
+ claudeCheap?: string;
8
+ opencodeCheap?: string;
9
+ };
10
+ };
11
+ export type LoadConfigOptions = {
12
+ cwd?: string;
13
+ globalDir?: string;
14
+ };
15
+ export declare function defaultGlobalDir(): string;
16
+ export declare function loadConfig(options?: LoadConfigOptions): FarqConfig;
17
+ /** Merge with later sources winning. Undefined flag fields do not wipe base. */
18
+ export declare function mergeConfig(base: FarqConfig, override: FarqConfig): FarqConfig;
package/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function defaultGlobalDir() {
5
+ return join(homedir(), ".config", "farq");
6
+ }
7
+ export function loadConfig(options = {}) {
8
+ const cwd = options.cwd ?? process.cwd();
9
+ const globalDir = options.globalDir ?? defaultGlobalDir();
10
+ const globalCfg = readJsonFile(join(globalDir, "config.json"));
11
+ const projectCfg = readJsonFile(join(cwd, ".farqrc.json")) ??
12
+ readJsonFile(join(cwd, ".farqrc"));
13
+ return mergeConfig(globalCfg ?? {}, projectCfg ?? {});
14
+ }
15
+ /** Merge with later sources winning. Undefined flag fields do not wipe base. */
16
+ export function mergeConfig(base, override) {
17
+ const out = {};
18
+ const provider = override.provider ?? base.provider;
19
+ const tone = override.tone ?? base.tone;
20
+ const models = {
21
+ ...base.models,
22
+ ...stripUndefined(override.models ?? {}),
23
+ };
24
+ if (provider !== undefined)
25
+ out.provider = provider;
26
+ if (tone !== undefined)
27
+ out.tone = tone;
28
+ if (Object.keys(models).length > 0)
29
+ out.models = models;
30
+ return out;
31
+ }
32
+ function stripUndefined(obj) {
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(obj)) {
35
+ if (v !== undefined)
36
+ out[k] = v;
37
+ }
38
+ return out;
39
+ }
40
+ function readJsonFile(path) {
41
+ if (!existsSync(path))
42
+ return null;
43
+ try {
44
+ const raw = readFileSync(path, "utf8");
45
+ const data = JSON.parse(raw);
46
+ return sanitize(data);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function sanitize(data) {
53
+ const out = {};
54
+ if (data.provider === "claude" ||
55
+ data.provider === "opencode" ||
56
+ data.provider === "fake") {
57
+ out.provider = data.provider;
58
+ }
59
+ if (data.tone === "technical" || data.tone === "client") {
60
+ out.tone = data.tone;
61
+ }
62
+ if (data.models && typeof data.models === "object") {
63
+ const models = {};
64
+ if (typeof data.models.claudeCheap === "string") {
65
+ models.claudeCheap = data.models.claudeCheap;
66
+ }
67
+ if (typeof data.models.opencodeCheap === "string") {
68
+ models.opencodeCheap = data.models.opencodeCheap;
69
+ }
70
+ if (Object.keys(models).length > 0)
71
+ out.models = models;
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Extract and parse the outermost JSON object from model output.
3
+ * Strips markdown fences and leading/trailing prose.
4
+ */
5
+ export declare function extractJson(text: string): unknown;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Extract and parse the outermost JSON object from model output.
3
+ * Strips markdown fences and leading/trailing prose.
4
+ */
5
+ export function extractJson(text) {
6
+ const trimmed = text.trim();
7
+ const unfenced = stripFences(trimmed);
8
+ const candidate = findOutermostObject(unfenced);
9
+ if (!candidate) {
10
+ throw new Error("No JSON object found in model output");
11
+ }
12
+ try {
13
+ return JSON.parse(candidate);
14
+ }
15
+ catch (err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ throw new Error(`Invalid JSON in model output: ${message}`);
18
+ }
19
+ }
20
+ function stripFences(text) {
21
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
22
+ if (fenced?.[1])
23
+ return fenced[1].trim();
24
+ return text;
25
+ }
26
+ function findOutermostObject(text) {
27
+ const start = text.indexOf("{");
28
+ if (start === -1)
29
+ return null;
30
+ let depth = 0;
31
+ let inString = false;
32
+ let escape = false;
33
+ for (let i = start; i < text.length; i++) {
34
+ const ch = text[i];
35
+ if (inString) {
36
+ if (escape) {
37
+ escape = false;
38
+ }
39
+ else if (ch === "\\") {
40
+ escape = true;
41
+ }
42
+ else if (ch === '"') {
43
+ inString = false;
44
+ }
45
+ continue;
46
+ }
47
+ if (ch === '"') {
48
+ inString = true;
49
+ continue;
50
+ }
51
+ if (ch === "{")
52
+ depth++;
53
+ if (ch === "}") {
54
+ depth--;
55
+ if (depth === 0)
56
+ return text.slice(start, i + 1);
57
+ }
58
+ }
59
+ return null;
60
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export declare const SUMMARY_DIFF_BUDGET = 150000;
2
+ export declare const VISUAL_DIFF_BUDGET = 60000;
3
+ export declare class NoChangesError extends Error {
4
+ constructor(message?: string);
5
+ }
6
+ export declare class GitError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ export type DiffFile = {
10
+ path: string;
11
+ status: string;
12
+ patch: string;
13
+ };
14
+ export type GatherDiffResult = {
15
+ mode: "range" | "worktree";
16
+ range: string;
17
+ baseRef: string | null;
18
+ headRef: string;
19
+ files: DiffFile[];
20
+ diffText: string;
21
+ truncated: boolean;
22
+ commits: string[];
23
+ };
24
+ export type GatherDiffOptions = {
25
+ cwd?: string;
26
+ range?: string;
27
+ budget?: number;
28
+ };
29
+ export declare function gatherDiff(options?: GatherDiffOptions): Promise<GatherDiffResult>;
30
+ export declare function getFileAtRef(cwd: string, ref: string, filePath: string): Promise<string | null>;
31
+ /** Size-capped before/after pairs for visual generation. */
32
+ export declare function gatherVisualFileContents(cwd: string, files: DiffFile[], baseRef: string | null, budget?: number): Promise<Array<{
33
+ path: string;
34
+ before: string;
35
+ after: string;
36
+ }>>;
package/dist/git.js ADDED
@@ -0,0 +1,277 @@
1
+ import { execa } from "execa";
2
+ export const SUMMARY_DIFF_BUDGET = 150_000;
3
+ export const VISUAL_DIFF_BUDGET = 60_000;
4
+ const GIT_TIMEOUT_MS = 30_000;
5
+ export class NoChangesError extends Error {
6
+ constructor(message = "No changes found in range — nothing to summarize") {
7
+ super(message);
8
+ this.name = "NoChangesError";
9
+ }
10
+ }
11
+ export class GitError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "GitError";
15
+ }
16
+ }
17
+ export async function gatherDiff(options = {}) {
18
+ const cwd = options.cwd ?? process.cwd();
19
+ const budget = options.budget ?? SUMMARY_DIFF_BUDGET;
20
+ await assertGitRepo(cwd);
21
+ if (options.range) {
22
+ const explicit = await gatherRangeDiff(cwd, options.range, budget);
23
+ if (explicit.files.length === 0 && !explicit.diffText.trim()) {
24
+ throw new NoChangesError();
25
+ }
26
+ return explicit;
27
+ }
28
+ const base = await resolveMergeBase(cwd);
29
+ const range = `${base}..HEAD`;
30
+ const committed = await gatherRangeDiff(cwd, range, budget);
31
+ if (committed.files.length > 0 || committed.diffText.trim()) {
32
+ return committed;
33
+ }
34
+ // merge-base..HEAD empty — fall through to worktree
35
+ const worktree = await gatherWorktreeDiff(cwd, budget);
36
+ if (worktree.files.length === 0) {
37
+ throw new NoChangesError();
38
+ }
39
+ return worktree;
40
+ }
41
+ export async function getFileAtRef(cwd, ref, filePath) {
42
+ try {
43
+ const { stdout } = await git(cwd, ["show", `${ref}:${filePath}`]);
44
+ return stdout;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Size-capped before/after pairs for visual generation. */
51
+ export async function gatherVisualFileContents(cwd, files, baseRef, budget = VISUAL_DIFF_BUDGET) {
52
+ const out = [];
53
+ let used = 0;
54
+ for (const file of files) {
55
+ if (used >= budget)
56
+ break;
57
+ const before = baseRef
58
+ ? ((await getFileAtRef(cwd, baseRef, file.path)) ?? "")
59
+ : "";
60
+ const after = (await getFileAtRef(cwd, "HEAD", file.path)) ??
61
+ (await readWorktreeFile(cwd, file.path)) ??
62
+ "";
63
+ const chunk = before.length + after.length;
64
+ if (used + chunk > budget && out.length > 0)
65
+ break;
66
+ out.push({
67
+ path: file.path,
68
+ before: before.slice(0, budget - used),
69
+ after: after.slice(0, Math.max(0, budget - used - before.length)),
70
+ });
71
+ used += Math.min(chunk, budget - used);
72
+ }
73
+ return out;
74
+ }
75
+ async function gatherRangeDiff(cwd, range, budget) {
76
+ const [baseRef, headRef] = splitRange(range);
77
+ const nameStatus = await git(cwd, [
78
+ "diff",
79
+ "--name-status",
80
+ "--find-renames",
81
+ range,
82
+ ]);
83
+ const patch = await git(cwd, ["diff", "--find-renames", "--unified=5", range]);
84
+ const commits = await listCommits(cwd, range);
85
+ const files = parseNameStatus(nameStatus.stdout, patch.stdout);
86
+ const { text, truncated } = capText(patch.stdout, budget);
87
+ return {
88
+ mode: "range",
89
+ range,
90
+ baseRef: baseRef === "HEAD" ? null : baseRef,
91
+ headRef,
92
+ files,
93
+ diffText: text,
94
+ truncated,
95
+ commits,
96
+ };
97
+ }
98
+ async function gatherWorktreeDiff(cwd, budget) {
99
+ const nameStatus = await git(cwd, [
100
+ "diff",
101
+ "HEAD",
102
+ "--name-status",
103
+ "--find-renames",
104
+ ]);
105
+ const patch = await git(cwd, ["diff", "HEAD", "--find-renames", "--unified=5"]);
106
+ const untracked = await listUntracked(cwd);
107
+ const files = parseNameStatus(nameStatus.stdout, patch.stdout);
108
+ let diffText = patch.stdout;
109
+ for (const path of untracked) {
110
+ if (!files.some((f) => f.path === path)) {
111
+ const content = (await readWorktreeFile(cwd, path)) ?? "";
112
+ const patchChunk = formatNewFilePatch(path, content);
113
+ files.push({ path, status: "A", patch: patchChunk });
114
+ diffText += (diffText.endsWith("\n") || !diffText ? "" : "\n") + patchChunk;
115
+ }
116
+ }
117
+ const capped = capText(diffText, budget);
118
+ return {
119
+ mode: "worktree",
120
+ range: "WORKTREE",
121
+ baseRef: "HEAD",
122
+ headRef: "WORKTREE",
123
+ files,
124
+ diffText: capped.text,
125
+ truncated: capped.truncated,
126
+ commits: [],
127
+ };
128
+ }
129
+ async function resolveMergeBase(cwd) {
130
+ const candidates = [
131
+ "origin/HEAD",
132
+ "origin/main",
133
+ "origin/master",
134
+ "main",
135
+ "master",
136
+ ];
137
+ for (const ref of candidates) {
138
+ try {
139
+ const { stdout } = await git(cwd, ["merge-base", "HEAD", ref]);
140
+ if (stdout.trim())
141
+ return stdout.trim();
142
+ }
143
+ catch {
144
+ // try next
145
+ }
146
+ }
147
+ // Fallback: first commit / empty tree against parent
148
+ try {
149
+ const { stdout } = await git(cwd, ["rev-parse", "HEAD~1"]);
150
+ return stdout.trim();
151
+ }
152
+ catch {
153
+ throw new NoChangesError("Could not resolve a merge base — provide --range explicitly");
154
+ }
155
+ }
156
+ async function assertGitRepo(cwd) {
157
+ try {
158
+ await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
159
+ }
160
+ catch {
161
+ throw new GitError("Not a git repository — run farq inside a repo (install: https://git-scm.com/)");
162
+ }
163
+ }
164
+ async function listCommits(cwd, range) {
165
+ try {
166
+ const { stdout } = await git(cwd, [
167
+ "log",
168
+ "--format=%s",
169
+ range.includes("..") ? range : `${range}`,
170
+ ]);
171
+ return stdout
172
+ .split("\n")
173
+ .map((s) => s.trim())
174
+ .filter(Boolean);
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
180
+ async function listUntracked(cwd) {
181
+ const { stdout } = await git(cwd, [
182
+ "ls-files",
183
+ "--others",
184
+ "--exclude-standard",
185
+ ]);
186
+ return stdout
187
+ .split("\n")
188
+ .map((s) => s.trim())
189
+ .filter(Boolean);
190
+ }
191
+ async function readWorktreeFile(cwd, filePath) {
192
+ try {
193
+ const { readFileSync } = await import("node:fs");
194
+ const { join } = await import("node:path");
195
+ return readFileSync(join(cwd, filePath), "utf8");
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ function formatNewFilePatch(path, content) {
202
+ const lines = content.split("\n");
203
+ const body = lines.map((l) => `+${l}`).join("\n");
204
+ return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`;
205
+ }
206
+ function parseNameStatus(nameStatus, patch) {
207
+ const files = [];
208
+ for (const line of nameStatus.split("\n")) {
209
+ if (!line.trim())
210
+ continue;
211
+ const parts = line.split("\t");
212
+ const status = parts[0] ?? "M";
213
+ const path = parts[parts.length - 1] ?? "";
214
+ if (!path)
215
+ continue;
216
+ files.push({
217
+ path,
218
+ status: status[0] ?? "M",
219
+ patch: extractFilePatch(patch, path),
220
+ });
221
+ }
222
+ return files;
223
+ }
224
+ function extractFilePatch(patch, path) {
225
+ const marker = `diff --git a/${path} b/${path}`;
226
+ const start = patch.indexOf(marker);
227
+ if (start === -1) {
228
+ // renamed or quoted paths — soft fallback
229
+ const idx = patch.indexOf(path);
230
+ if (idx === -1)
231
+ return "";
232
+ }
233
+ const from = start === -1 ? 0 : start;
234
+ const rest = patch.slice(from + (start === -1 ? 0 : marker.length));
235
+ const next = rest.indexOf("\ndiff --git ");
236
+ if (start === -1)
237
+ return "";
238
+ return patch.slice(start, next === -1 ? undefined : start + marker.length + next);
239
+ }
240
+ function splitRange(range) {
241
+ if (range.includes("...")) {
242
+ const [a, b] = range.split("...");
243
+ return [a || "HEAD", b || "HEAD"];
244
+ }
245
+ if (range.includes("..")) {
246
+ const [a, b] = range.split("..");
247
+ return [a || "HEAD", b || "HEAD"];
248
+ }
249
+ return [range, "HEAD"];
250
+ }
251
+ function capText(text, budget) {
252
+ if (text.length <= budget)
253
+ return { text, truncated: false };
254
+ return {
255
+ text: text.slice(0, budget) +
256
+ `\n\n… [farq truncated diff at ${budget} bytes]\n`,
257
+ truncated: true,
258
+ };
259
+ }
260
+ async function git(cwd, args) {
261
+ try {
262
+ return await execa("git", args, {
263
+ cwd,
264
+ timeout: GIT_TIMEOUT_MS,
265
+ reject: true,
266
+ stdout: "pipe",
267
+ stderr: "pipe",
268
+ });
269
+ }
270
+ catch (err) {
271
+ const e = err;
272
+ if (e.timedOut) {
273
+ throw new GitError("git did not respond — check that the repository is healthy");
274
+ }
275
+ throw err;
276
+ }
277
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};