@payconductor/houndsman 0.1.4 → 0.3.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.
- package/package.json +1 -1
- package/src/cli/arg-parser.ts +8 -0
- package/src/cli/commands/command.ts +23 -0
- package/src/cli/commands/context-command.ts +38 -0
- package/src/cli/commands/doctor-command.ts +55 -0
- package/src/cli/commands/findings-command.ts +28 -0
- package/src/cli/commands/help-command.ts +13 -0
- package/src/cli/commands/registry.ts +34 -0
- package/src/cli/commands/review-command.ts +22 -0
- package/src/cli/commands/server-command.ts +37 -0
- package/src/cli/commands/setup-command.ts +34 -0
- package/src/cli/commands/watchdog-command.ts +34 -0
- package/src/cli/help.ts +43 -0
- package/src/cli/index.ts +62 -260
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/findings.ts +163 -147
- package/src/core/commands/review.ts +18 -35
- package/src/core/commands/scaffold.ts +174 -64
- package/src/core/commands/setup.ts +16 -11
- package/src/core/commands/update.ts +7 -7
- package/src/core/commands/watchdog.ts +8 -80
- package/src/core/config.ts +9 -19
- package/src/core/defaults.ts +20 -0
- package/src/core/diff-source.ts +1 -13
- package/src/core/doc-discoverer.ts +25 -15
- package/src/core/domain/budget.ts +42 -0
- package/src/core/domain/classification.ts +94 -0
- package/src/core/domain/diff.ts +53 -0
- package/src/core/domain/watchdog.ts +96 -0
- package/src/core/engine/carryover.ts +67 -0
- package/src/core/engine/houndsman-engine.ts +51 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +51 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +189 -38
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +423 -0
- package/src/core/orchestrator.ts +116 -337
- package/src/core/paths.ts +9 -0
- package/src/core/pr-context.ts +28 -54
- package/src/core/project-config.ts +57 -0
- package/src/core/renderers/batch-presenter.ts +40 -0
- package/src/core/renderers/brief-renderer.ts +23 -0
- package/src/core/renderers/findings-presenter.ts +74 -0
- package/src/core/renderers/manifest-writer.ts +112 -0
- package/src/core/renderers/report-renderer.ts +66 -0
- package/src/core/renderers/verify-presenter.ts +50 -0
- package/src/core/renderers/watchdog-presenter.ts +100 -0
- package/src/core/report-builder.ts +12 -7
- package/src/core/run-layout.ts +108 -0
- package/src/core/run-status.ts +6 -11
- package/src/core/services/clock.ts +12 -0
- package/src/core/services/file-classifier.ts +34 -0
- package/src/core/services/fix-batch-builder.ts +13 -0
- package/src/core/services/gh-client.ts +54 -0
- package/src/core/services/git-repository.ts +123 -0
- package/src/core/services/project-files.ts +79 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +31 -1
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +316 -222
- package/src/server/run-repository.ts +291 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
- package/src/tests/baseline.test.ts +513 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/infrastructure.test.ts +210 -0
- package/src/tests/server.test.ts +117 -0
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync,
|
|
15
|
-
import {
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
16
|
import {
|
|
17
17
|
buildAnchor,
|
|
18
18
|
type FindingAnchor,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
locateAnchor,
|
|
21
21
|
reanchor,
|
|
22
22
|
} from "./finding-anchor";
|
|
23
|
-
import { STATE_FILE } from "./paths";
|
|
24
23
|
import {
|
|
25
24
|
dedupeFindings,
|
|
26
25
|
type MergedFinding,
|
|
@@ -28,8 +27,13 @@ import {
|
|
|
28
27
|
sanitizeBranch,
|
|
29
28
|
type WatchdogReport,
|
|
30
29
|
} from "./report-builder";
|
|
30
|
+
import { RunLayout } from "./run-layout";
|
|
31
|
+
import { systemClock } from "./services/clock";
|
|
32
|
+
import { type FileReader, ProjectFiles } from "./services/project-files";
|
|
31
33
|
import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
|
|
32
34
|
|
|
35
|
+
export type { FileReader };
|
|
36
|
+
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
// Tipos
|
|
35
39
|
// ---------------------------------------------------------------------------
|
|
@@ -67,6 +71,22 @@ export type FindingHistoryEntry = {
|
|
|
67
71
|
by?: string;
|
|
68
72
|
};
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Vínculo com o comentário remoto no GitHub (envio da revisão). Gravado pelo
|
|
76
|
+
* `findings submit` / UI — permite pular reenvios e abrir o comentário.
|
|
77
|
+
*/
|
|
78
|
+
export type GitHubLink = {
|
|
79
|
+
/** Id do comentário (review comment inline ou issue comment geral). */
|
|
80
|
+
commentId: number;
|
|
81
|
+
url?: string;
|
|
82
|
+
/** Origem do comentário remoto: linha, arquivo ou comentário geral. */
|
|
83
|
+
kind: "line" | "file" | "comment";
|
|
84
|
+
/** Número da PR de destino. */
|
|
85
|
+
pr: number;
|
|
86
|
+
/** Quando foi enviado (ISO). */
|
|
87
|
+
at: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
70
90
|
/** Marco da revisão que originou o estado: commit revisado + quando. */
|
|
71
91
|
export type FindingsReviewed = {
|
|
72
92
|
/** SHA do commit revisado (head do diff no `--finalize`). */
|
|
@@ -99,6 +119,8 @@ export type FindingState = {
|
|
|
99
119
|
createdAt: string;
|
|
100
120
|
updatedAt: string;
|
|
101
121
|
history: FindingHistoryEntry[];
|
|
122
|
+
/** Vínculo com o comentário remoto no GitHub (envio da revisão). */
|
|
123
|
+
github?: GitHubLink;
|
|
102
124
|
};
|
|
103
125
|
|
|
104
126
|
/** Conteúdo do `state.json`. */
|
|
@@ -122,27 +144,17 @@ export const FINDINGS_STATE_VERSION = 2;
|
|
|
122
144
|
|
|
123
145
|
/** Diretório de artefatos da run (branch-slug). */
|
|
124
146
|
export function runDir(artifactDir: string, branch: string): string {
|
|
125
|
-
return
|
|
147
|
+
return new RunLayout(artifactDir, branch).outDir;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
/** Caminho do `state.json` da run. */
|
|
129
151
|
export function stateFile(artifactDir: string, branch: string): string {
|
|
130
|
-
return
|
|
152
|
+
return new RunLayout(artifactDir, branch).stateFile;
|
|
131
153
|
}
|
|
132
154
|
|
|
133
|
-
/** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
|
|
134
|
-
export type FileReader = (relativePath: string) => string | null;
|
|
135
|
-
|
|
136
155
|
/** Leitor padrão a partir do cwd (null se ausente/ilegível). */
|
|
137
156
|
export function fileReader(cwd: string): FileReader {
|
|
138
|
-
return (
|
|
139
|
-
try {
|
|
140
|
-
const full = join(cwd, relativePath);
|
|
141
|
-
return existsSync(full) ? readFileSync(full, "utf8") : null;
|
|
142
|
-
} catch {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
};
|
|
157
|
+
return new ProjectFiles(cwd).reader();
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/** Id curto aleatório (hex) para um finding novo. */
|
|
@@ -156,7 +168,7 @@ export function emptyState(branch: string): FindingsStateFile {
|
|
|
156
168
|
version: FINDINGS_STATE_VERSION,
|
|
157
169
|
branch,
|
|
158
170
|
slug: sanitizeBranch(branch),
|
|
159
|
-
updatedAt:
|
|
171
|
+
updatedAt: systemClock(),
|
|
160
172
|
order: [],
|
|
161
173
|
findings: {},
|
|
162
174
|
};
|
|
@@ -279,7 +291,7 @@ export function buildStateFromFindings(
|
|
|
279
291
|
merged: readonly MergedFinding[],
|
|
280
292
|
options: BuildStateOptions,
|
|
281
293
|
): FindingsStateFile {
|
|
282
|
-
const now = options.now ??
|
|
294
|
+
const now = options.now ?? systemClock();
|
|
283
295
|
const previous = options.previous ?? null;
|
|
284
296
|
|
|
285
297
|
const result: Record<string, FindingState> = {};
|
|
@@ -345,6 +357,7 @@ export function buildStateFromFindings(
|
|
|
345
357
|
createdAt: prev?.createdAt ?? now,
|
|
346
358
|
updatedAt: now,
|
|
347
359
|
history: prev?.history ?? [],
|
|
360
|
+
github: prev?.github,
|
|
348
361
|
};
|
|
349
362
|
byFingerprint.set(fingerprint, created);
|
|
350
363
|
result[created.id] = created;
|
|
@@ -418,7 +431,7 @@ export type FindingUpdate = {
|
|
|
418
431
|
export function applyFindingUpdate(
|
|
419
432
|
finding: FindingState,
|
|
420
433
|
update: FindingUpdate,
|
|
421
|
-
now: string =
|
|
434
|
+
now: string = systemClock(),
|
|
422
435
|
): FindingState {
|
|
423
436
|
if (update.decision && DECISION_SET.has(update.decision)) {
|
|
424
437
|
finding.decision = update.decision;
|
|
@@ -452,7 +465,7 @@ export type SyncResult = {
|
|
|
452
465
|
export function syncState(
|
|
453
466
|
state: FindingsStateFile,
|
|
454
467
|
readFile: FileReader,
|
|
455
|
-
now: string =
|
|
468
|
+
now: string = systemClock(),
|
|
456
469
|
): SyncResult {
|
|
457
470
|
const moved: string[] = [];
|
|
458
471
|
const stale: string[] = [];
|
|
@@ -502,22 +515,165 @@ export function occurrenceCount(
|
|
|
502
515
|
}
|
|
503
516
|
|
|
504
517
|
// ---------------------------------------------------------------------------
|
|
505
|
-
//
|
|
518
|
+
// Domínio (classes)
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
/** Um finding triável, com comportamento sobre o DTO serializável. */
|
|
522
|
+
export class Finding {
|
|
523
|
+
readonly state: FindingState;
|
|
524
|
+
|
|
525
|
+
constructor(state: FindingState) {
|
|
526
|
+
this.state = state;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
static from(state: FindingState): Finding {
|
|
530
|
+
return new Finding(state);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
get id(): string {
|
|
534
|
+
return this.state.id;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
get file(): string {
|
|
538
|
+
return this.state.file;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
get line(): number | null {
|
|
542
|
+
return this.state.line;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
get severity(): Severity {
|
|
546
|
+
return this.state.severity;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
get decision(): FindingDecision {
|
|
550
|
+
return this.state.decision;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
get execState(): FindingExecState {
|
|
554
|
+
return this.state.execState;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
get present(): boolean {
|
|
558
|
+
return this.state.present;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
get isDone(): boolean {
|
|
562
|
+
return this.state.execState === "done";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Aplica uma atualização (com histórico) e devolve o próprio finding. */
|
|
566
|
+
apply(update: FindingUpdate, now: string = systemClock()): Finding {
|
|
567
|
+
applyFindingUpdate(this.state, update, now);
|
|
568
|
+
return this;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
572
|
+
toJSON(): FindingState {
|
|
573
|
+
return this.state;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Coleção de triagem de uma run — superfície OO sobre o `state.json`. */
|
|
578
|
+
export class FindingsState {
|
|
579
|
+
readonly file: FindingsStateFile;
|
|
580
|
+
|
|
581
|
+
constructor(file: FindingsStateFile) {
|
|
582
|
+
this.file = file;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
static from(file: FindingsStateFile): FindingsState {
|
|
586
|
+
return new FindingsState(file);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
static empty(branch: string): FindingsState {
|
|
590
|
+
return new FindingsState(emptyState(branch));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
get branch(): string {
|
|
594
|
+
return this.file.branch;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
get reviewed(): FindingsReviewed | undefined {
|
|
598
|
+
return this.file.reviewed;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Findings na ordem de exibição (`order`). */
|
|
602
|
+
list(): Finding[] {
|
|
603
|
+
return this.file.order
|
|
604
|
+
.map((id) => this.file.findings[id])
|
|
605
|
+
.filter((state): state is FindingState => Boolean(state))
|
|
606
|
+
.map((state) => Finding.from(state));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Busca por id. */
|
|
610
|
+
get(id: string): Finding | null {
|
|
611
|
+
const state = this.file.findings[id];
|
|
612
|
+
return state ? Finding.from(state) : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Todos os findings (ordem de inserção, sem o `order`). */
|
|
616
|
+
values(): Finding[] {
|
|
617
|
+
return Object.values(this.file.findings).map((state) => Finding.from(state));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Somente os presentes no último relatório. */
|
|
621
|
+
present(): Finding[] {
|
|
622
|
+
return this.values().filter((finding) => finding.present);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Filtra pela ordem canônica (presentes → severidade → arquivo → linha). */
|
|
626
|
+
filter(filter: FindingsFilter = {}): Finding[] {
|
|
627
|
+
return filterFindings(this.file, filter).map((state) => Finding.from(state));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Contagens por decisão, execução e severidade. */
|
|
631
|
+
summarize(): FindingsSummary {
|
|
632
|
+
return summarize(this.file);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Reordena os ids pela ordenação canônica. */
|
|
636
|
+
reorder(): void {
|
|
637
|
+
reorder(this.file);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Aplica uma atualização a um finding e devolve-o (null se ausente). */
|
|
641
|
+
apply(id: string, update: FindingUpdate, now?: string): Finding | null {
|
|
642
|
+
const state = this.file.findings[id];
|
|
643
|
+
if (!state) return null;
|
|
644
|
+
applyFindingUpdate(state, update, now);
|
|
645
|
+
return Finding.from(state);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Reancora os presentes contra os arquivos atuais. */
|
|
649
|
+
sync(readFile: FileReader, now?: string): SyncResult {
|
|
650
|
+
return syncState(this.file, readFile, now);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Payload serializável (mesmo shape do `state.json`). */
|
|
654
|
+
toJSON(): FindingsStateFile {
|
|
655
|
+
return this.file;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Repository (I/O atômico)
|
|
506
661
|
// ---------------------------------------------------------------------------
|
|
507
662
|
|
|
508
663
|
/** Persistência do `state.json` — escrita atômica (tmp + rename). */
|
|
509
|
-
export class
|
|
664
|
+
export class FindingsRepository {
|
|
510
665
|
private readonly path: string;
|
|
511
666
|
private readonly branch: string;
|
|
667
|
+
private readonly files = new ProjectFiles();
|
|
512
668
|
|
|
513
669
|
constructor(path: string, branch: string) {
|
|
514
670
|
this.path = path;
|
|
515
671
|
this.branch = branch;
|
|
516
672
|
}
|
|
517
673
|
|
|
518
|
-
/**
|
|
519
|
-
static at(artifactDir: string, branch: string):
|
|
520
|
-
return new
|
|
674
|
+
/** Repository para a run de uma branch. */
|
|
675
|
+
static at(artifactDir: string, branch: string): FindingsRepository {
|
|
676
|
+
return new FindingsRepository(stateFile(artifactDir, branch), branch);
|
|
521
677
|
}
|
|
522
678
|
|
|
523
679
|
get filePath(): string {
|
|
@@ -526,31 +682,26 @@ export class FindingsStateStore {
|
|
|
526
682
|
|
|
527
683
|
/** Lê o estado; null se ausente/inválido. */
|
|
528
684
|
read(): FindingsStateFile | null {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (raw?.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
533
|
-
return raw;
|
|
534
|
-
} catch {
|
|
535
|
-
return null;
|
|
536
|
-
}
|
|
685
|
+
const raw = this.files.readJson<FindingsStateFile>(this.path);
|
|
686
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
687
|
+
return raw;
|
|
537
688
|
}
|
|
538
689
|
|
|
539
690
|
/** Grava o estado de forma atômica. */
|
|
540
691
|
write(state: FindingsStateFile): void {
|
|
541
|
-
|
|
542
|
-
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
543
|
-
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
544
|
-
renameSync(tmp, this.path);
|
|
692
|
+
this.files.writeAtomic(this.path, `${JSON.stringify(state, null, 2)}\n`);
|
|
545
693
|
}
|
|
546
694
|
|
|
547
695
|
/** Lê-modifica-grava; parte de um estado vazio se não existir. */
|
|
548
696
|
update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
|
|
549
697
|
const state = this.read() ?? emptyState(this.branch);
|
|
550
698
|
mutator(state);
|
|
551
|
-
state.updatedAt =
|
|
699
|
+
state.updatedAt = systemClock();
|
|
552
700
|
reorder(state);
|
|
553
701
|
this.write(state);
|
|
554
702
|
return state;
|
|
555
703
|
}
|
|
556
704
|
}
|
|
705
|
+
|
|
706
|
+
/** Nome anterior do repository (compatibilidade da API pública). */
|
|
707
|
+
export { FindingsRepository as FindingsStateStore };
|
package/src/core/git-revision.ts
CHANGED
|
@@ -11,31 +11,20 @@
|
|
|
11
11
|
* (`parseEntries`) — não reimplementa leitura de diff.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
DEFAULT_MAX_PER_FILE_CHARS,
|
|
16
|
-
type DiffSource,
|
|
17
|
-
parseDiffIndex,
|
|
18
|
-
parseNumstat,
|
|
19
|
-
} from "./diff-source";
|
|
14
|
+
import { DEFAULT_MAX_PER_FILE_CHARS, type DiffSource } from "./diff-source";
|
|
20
15
|
import { parseEntries } from "./file-classifier";
|
|
16
|
+
import { runGit } from "./git-runner";
|
|
17
|
+
import { GitRepository } from "./services/git-repository";
|
|
21
18
|
import type { FileChurn } from "./types";
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
stderr: "pipe",
|
|
28
|
-
});
|
|
29
|
-
if (result.exitCode !== 0) {
|
|
30
|
-
const stderr = new TextDecoder().decode(result.stderr).trim();
|
|
31
|
-
throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
|
|
32
|
-
}
|
|
33
|
-
return new TextDecoder().decode(result.stdout);
|
|
34
|
-
}
|
|
20
|
+
export { runGit };
|
|
21
|
+
|
|
22
|
+
/** Repositório padrão (runner real) usado pelos helpers funcionais. */
|
|
23
|
+
const git = new GitRepository();
|
|
35
24
|
|
|
36
25
|
/** SHA completo de uma ref (`git rev-parse`). Lança se a ref não resolver. */
|
|
37
26
|
export function resolveSha(ref: string): string {
|
|
38
|
-
return
|
|
27
|
+
return git.resolveSha(ref);
|
|
39
28
|
}
|
|
40
29
|
|
|
41
30
|
/** SHA do commit atual (`HEAD`). */
|
|
@@ -50,31 +39,22 @@ export function currentHeadSha(): string {
|
|
|
50
39
|
* comportamento seguro.
|
|
51
40
|
*/
|
|
52
41
|
export function isAncestor(ancestor: string, descendant: string): boolean {
|
|
53
|
-
|
|
54
|
-
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
55
|
-
{ stdout: "ignore", stderr: "ignore" },
|
|
56
|
-
);
|
|
57
|
-
return result.exitCode === 0;
|
|
42
|
+
return git.isAncestor(ancestor, descendant);
|
|
58
43
|
}
|
|
59
44
|
|
|
60
45
|
/** Conteúdo de um arquivo num commit (`git show sha:path`); null se ausente. */
|
|
61
46
|
export function readFileAt(sha: string, path: string): string | null {
|
|
62
|
-
|
|
63
|
-
["git", "-c", "core.quotepath=false", "show", `${sha}:${path}`],
|
|
64
|
-
{ stdout: "pipe", stderr: "ignore" },
|
|
65
|
-
);
|
|
66
|
-
if (result.exitCode !== 0) return null;
|
|
67
|
-
return new TextDecoder().decode(result.stdout);
|
|
47
|
+
return git.readFileAt(sha, path);
|
|
68
48
|
}
|
|
69
49
|
|
|
70
50
|
/** Arquivos alterados num intervalo (`git diff range --name-status`). */
|
|
71
51
|
export function diffEntries(range: string): ReturnType<typeof parseEntries> {
|
|
72
|
-
return parseEntries(
|
|
52
|
+
return parseEntries(git.run(["diff", range, "--name-status"]));
|
|
73
53
|
}
|
|
74
54
|
|
|
75
55
|
/** Churn por arquivo de um intervalo (`git diff --numstat -z range`). */
|
|
76
56
|
export function diffNumstat(range: string): Map<string, FileChurn> {
|
|
77
|
-
return
|
|
57
|
+
return git.diffNumstat(range);
|
|
78
58
|
}
|
|
79
59
|
|
|
80
60
|
/**
|
|
@@ -86,9 +66,7 @@ export function diffPatch(
|
|
|
86
66
|
paths?: readonly string[],
|
|
87
67
|
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
88
68
|
): Map<string, string> {
|
|
89
|
-
|
|
90
|
-
if (paths && paths.length > 0) args.push("--", ...paths);
|
|
91
|
-
return parseDiffIndex(runGit(args), maxPerFileChars);
|
|
69
|
+
return git.diffPatch(range, paths, maxPerFileChars);
|
|
92
70
|
}
|
|
93
71
|
|
|
94
72
|
/** Diff (patch + churn) de `from..to` — 2 spawns, base do review incremental. */
|
|
@@ -97,9 +75,5 @@ export function collectDiffSince(
|
|
|
97
75
|
to: string,
|
|
98
76
|
maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
|
|
99
77
|
): DiffSource {
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
byFile: parseDiffIndex(runGit(["diff", range]), maxPerFileChars),
|
|
103
|
-
churn: diffNumstat(range),
|
|
104
|
-
};
|
|
78
|
+
return git.collectDiffSince(from, to, maxPerFileChars);
|
|
105
79
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execução do binário `git` (biblioteca) — única camada que fala com o processo.
|
|
3
|
+
*
|
|
4
|
+
* `core.quotepath=false` mantém paths literais (acentos/unicode). O runner é
|
|
5
|
+
* injetável nos testes; `GitRepository` é a superfície OO sobre estes helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Resultado cru de uma execução do git (nunca lança). */
|
|
9
|
+
export type GitResult = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Executa `git` e devolve o resultado cru. Injetável nos testes. */
|
|
17
|
+
export type GitRunner = (args: string[]) => GitResult;
|
|
18
|
+
|
|
19
|
+
/** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
|
|
20
|
+
export const defaultGitRunner: GitRunner = (args) => {
|
|
21
|
+
const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
const code = result.exitCode ?? 1;
|
|
26
|
+
return {
|
|
27
|
+
ok: code === 0,
|
|
28
|
+
code,
|
|
29
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
30
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Executa e devolve o stdout; lança com o stderr quando o git falha. */
|
|
35
|
+
export function runGit(args: string[], runner: GitRunner = defaultGitRunner): string {
|
|
36
|
+
const result = runner(args);
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
throw new Error(`git ${args.join(" ")} falhou: ${result.stderr.trim()}`);
|
|
39
|
+
}
|
|
40
|
+
return result.stdout;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Executa o git sem lançar (para sondagens como `merge-base`/`show`). */
|
|
44
|
+
export function tryGit(args: string[], runner: GitRunner = defaultGitRunner): GitResult {
|
|
45
|
+
return runner(args);
|
|
46
|
+
}
|