@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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. 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, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
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 join(artifactDir, sanitizeBranch(branch));
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 join(runDir(artifactDir, branch), STATE_FILE);
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 (relativePath: string) => {
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: new Date().toISOString(),
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 ?? new Date().toISOString();
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 = new Date().toISOString(),
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 = new Date().toISOString(),
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
- // Store (I/O atômico)
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 FindingsStateStore {
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
- /** Store para a run de uma branch. */
519
- static at(artifactDir: string, branch: string): FindingsStateStore {
520
- return new FindingsStateStore(stateFile(artifactDir, branch), branch);
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
- if (!existsSync(this.path)) return null;
530
- try {
531
- const raw = JSON.parse(readFileSync(this.path, "utf8")) as FindingsStateFile;
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
- mkdirSync(dirname(this.path), { recursive: true });
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 = new Date().toISOString();
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 };
@@ -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
- /** Executa `git` (paths literais) e devolve o stdout; lança se falhar. */
24
- export function runGit(args: string[]): string {
25
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
26
- stdout: "pipe",
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 runGit(["rev-parse", ref]).trim();
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
- const result = Bun.spawnSync(
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
- const result = Bun.spawnSync(
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(runGit(["diff", range, "--name-status"]));
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 parseNumstat(runGit(["diff", "--numstat", "-z", range]));
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
- const args = ["diff", range];
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
- const range = `${from}..${to}`;
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
+ }