@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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +74 -0
- package/dist/extract-json.d.ts +5 -0
- package/dist/extract-json.js +60 -0
- package/dist/git.d.ts +36 -0
- package/dist/git.js +277 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +204 -0
- package/dist/open-pr.d.ts +26 -0
- package/dist/open-pr.js +199 -0
- package/dist/providers/claude.d.ts +3 -0
- package/dist/providers/claude.js +88 -0
- package/dist/providers/fake.d.ts +8 -0
- package/dist/providers/fake.js +43 -0
- package/dist/providers/index.d.ts +22 -0
- package/dist/providers/index.js +40 -0
- package/dist/providers/opencode.d.ts +3 -0
- package/dist/providers/opencode.js +33 -0
- package/dist/render/json.d.ts +2 -0
- package/dist/render/json.js +3 -0
- package/dist/render/pr.d.ts +7 -0
- package/dist/render/pr.js +48 -0
- package/dist/render/slack.d.ts +2 -0
- package/dist/render/slack.js +25 -0
- package/dist/schema.d.ts +74 -0
- package/dist/schema.js +39 -0
- package/dist/summarize.d.ts +17 -0
- package/dist/summarize.js +72 -0
- package/dist/template.d.ts +11 -0
- package/dist/template.js +87 -0
- package/dist/title.d.ts +12 -0
- package/dist/title.js +37 -0
- package/dist/visual/chrome.d.ts +12 -0
- package/dist/visual/chrome.js +62 -0
- package/dist/visual/compose.d.ts +16 -0
- package/dist/visual/compose.js +58 -0
- package/dist/visual/diagram.d.ts +17 -0
- package/dist/visual/diagram.js +36 -0
- package/dist/visual/gate.d.ts +11 -0
- package/dist/visual/gate.js +28 -0
- package/dist/visual/mockup.d.ts +26 -0
- package/dist/visual/mockup.js +46 -0
- package/dist/visual/pipeline.d.ts +23 -0
- package/dist/visual/pipeline.js +126 -0
- 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
|
package/dist/config.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/index.d.ts
ADDED