@payconductor/houndsman 0.7.0 → 0.10.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/README.md +20 -0
- package/package.json +1 -1
- package/src/cli/commands/command.ts +10 -1
- package/src/cli/commands/context-command.ts +40 -2
- package/src/cli/commands/hunt-command.ts +28 -2
- package/src/cli/commands/review-command.ts +30 -2
- package/src/cli/commands/worktree-command.ts +118 -0
- package/src/cli/help.ts +12 -2
- package/src/cli/index.ts +14 -4
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/context.ts +3 -3
- package/src/core/ai/prompts/technical.ts +30 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/ast/service.ts +7 -2
- package/src/core/brief.ts +26 -4
- package/src/core/commands/context.ts +9 -1
- package/src/core/commands/findings.ts +11 -5
- package/src/core/commands/review.ts +13 -0
- package/src/core/commands/scaffold.ts +9 -2
- package/src/core/config.ts +30 -1
- package/src/core/defaults.ts +7 -0
- package/src/core/engine/houndsman-engine.ts +26 -3
- package/src/core/engine/review-scope.ts +17 -22
- package/src/core/engine/review-session.ts +25 -2
- package/src/core/engine/review-workspace.ts +204 -0
- package/src/core/git-runner.ts +8 -1
- package/src/core/jev/jev-detector.ts +10 -2
- package/src/core/orchestrator.ts +176 -15
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +21 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/git-repository.ts +46 -0
- package/src/core/services/pr-resolver.ts +147 -0
- package/src/core/services/worktree-manager.ts +293 -0
- package/src/core/storage/file/file-storage.ts +14 -1
- package/src/core/storage/paths.ts +30 -1
- package/src/core/storage/sqlite/mappers.ts +4 -0
- package/src/core/storage/sqlite/schema.ts +13 -3
- package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
- package/src/core/storage/types.ts +13 -0
- package/src/core/storage/work-area.ts +20 -1
- package/src/core/technical-summary.ts +398 -0
- package/src/core/types.ts +2 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/harness/hunt/hunt-options.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +7 -20
- package/src/index.ts +33 -0
- package/src/server/client/App.tsx +51 -12
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +17 -20
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -358
- package/src/server/client/icons.tsx +10 -0
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +27 -0
- package/src/server/index.ts +113 -406
- package/src/server/pr-inbox.ts +15 -4
- package/src/server/run-repository.ts +103 -2
- package/src/server/styles.css +454 -0
- package/src/skill/README.md +11 -3
- package/src/skill/SKILL.md +44 -10
- package/src/skill/hunt.md +33 -7
- package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +36 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/cli.test.ts +2 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +54 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +404 -0
- package/assets/logo-legacy.png +0 -0
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
AiProviderKind,
|
|
11
|
+
AiProviderOptions,
|
|
11
12
|
HoundsmanAi,
|
|
12
13
|
HoundsmanAiFeatures,
|
|
13
14
|
HoundsmanAst,
|
|
14
15
|
HoundsmanConfig,
|
|
15
16
|
HoundsmanStorage,
|
|
17
|
+
HoundsmanWorktrees,
|
|
16
18
|
} from "./config";
|
|
17
19
|
import {
|
|
18
20
|
DEFAULT_AI_CONCURRENCY,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
DEFAULT_AST_MAX_IMPACT_FILES,
|
|
28
30
|
DEFAULT_JEV_CONCURRENCY,
|
|
29
31
|
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
32
|
+
DEFAULT_WORKTREES_LOCATION,
|
|
30
33
|
} from "./defaults";
|
|
31
34
|
import { WatchdogRegistry } from "./watchdog-registry";
|
|
32
35
|
|
|
@@ -48,6 +51,16 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
48
51
|
return this.raw.storage ?? {};
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/** Config de worktrees (objeto vazio quando ausente). */
|
|
55
|
+
get worktrees(): HoundsmanWorktrees {
|
|
56
|
+
return this.raw.worktrees ?? {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Localização dos worktrees resolvida (default: workspace). */
|
|
60
|
+
get worktreesLocation(): string {
|
|
61
|
+
return this.worktrees.location?.trim() || DEFAULT_WORKTREES_LOCATION;
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/** Config do AST (objeto vazio quando ausente). */
|
|
52
65
|
get ast(): HoundsmanAst {
|
|
53
66
|
return this.raw.ast ?? {};
|
|
@@ -126,6 +139,12 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
126
139
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
127
140
|
}
|
|
128
141
|
|
|
142
|
+
/** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
|
|
143
|
+
get aiProviderOptions(): AiProviderOptions | undefined {
|
|
144
|
+
const value = this.ai.providerOptions;
|
|
145
|
+
return value && Object.keys(value).length > 0 ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
get aiTimeoutMs(): number {
|
|
130
149
|
return positive(this.ai.timeoutMs, DEFAULT_AI_TIMEOUT_MS);
|
|
131
150
|
}
|
|
@@ -140,12 +159,13 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
140
159
|
return value !== undefined && value > 0 ? value : undefined;
|
|
141
160
|
}
|
|
142
161
|
|
|
143
|
-
/** Features resolvidas com defaults (`context`/`brief` ligadas). */
|
|
162
|
+
/** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
|
|
144
163
|
get aiFeatures(): Required<HoundsmanAiFeatures> {
|
|
145
164
|
const features = this.ai.features ?? {};
|
|
146
165
|
return {
|
|
147
166
|
context: features.context !== false,
|
|
148
167
|
brief: features.brief !== false,
|
|
168
|
+
technical: features.technical !== false,
|
|
149
169
|
classification: features.classification === true,
|
|
150
170
|
preAnalysis: features.preAnalysis === true,
|
|
151
171
|
verify: features.verify === true,
|
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
|
|
5
5
|
* watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
|
|
6
|
-
* negócio da PR aos
|
|
6
|
+
* negócio da PR aos pontos técnicos estruturais da revisão, com limite de
|
|
7
7
|
* `RESUME_MAX_CHARS`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
11
|
-
ContextSummary,
|
|
12
|
-
MergedFinding,
|
|
13
|
-
WatchdogNameResolver,
|
|
14
|
-
} from "../report-builder";
|
|
10
|
+
import type { ContextSummary, MergedFinding } from "../report-builder";
|
|
15
11
|
import { systemClock } from "../services/clock";
|
|
12
|
+
import type { TechnicalSummary } from "../technical-summary";
|
|
16
13
|
|
|
17
14
|
/** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
|
|
18
|
-
export const RESUME_MAX_CHARS =
|
|
15
|
+
export const RESUME_MAX_CHARS = 5000;
|
|
19
16
|
|
|
20
17
|
/** Dados de classificação usados no resumo. */
|
|
21
18
|
export type ResumeClassification = {
|
|
@@ -30,11 +27,10 @@ export type ReviewResumeInput = {
|
|
|
30
27
|
context?: ContextSummary | null;
|
|
31
28
|
classification: ResumeClassification;
|
|
32
29
|
findings: readonly MergedFinding[];
|
|
33
|
-
|
|
30
|
+
/** Pontos técnicos estruturais (não achados). */
|
|
31
|
+
technical?: TechnicalSummary | null;
|
|
34
32
|
};
|
|
35
33
|
|
|
36
|
-
const identityName: WatchdogNameResolver = (id) => id;
|
|
37
|
-
|
|
38
34
|
const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
|
|
39
35
|
blocking: "❌ Mudanças necessárias",
|
|
40
36
|
attention: "⚠️ Aprovado com ressalvas",
|
|
@@ -80,36 +76,24 @@ function businessSection(input: ReviewResumeInput): string[] {
|
|
|
80
76
|
if (context.objective) lines.push(context.objective, "");
|
|
81
77
|
if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
|
|
82
78
|
if (context.changeSummary && context.changeSummary.length > 0) {
|
|
83
|
-
for (const item of context.changeSummary.slice(0,
|
|
84
|
-
lines.push(`- ${truncate(item,
|
|
79
|
+
for (const item of context.changeSummary.slice(0, 6)) {
|
|
80
|
+
lines.push(`- ${truncate(item, 240)}`);
|
|
85
81
|
}
|
|
86
82
|
lines.push("");
|
|
87
83
|
}
|
|
88
84
|
return lines;
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
/**
|
|
92
|
-
function technicalSection(
|
|
93
|
-
findings: readonly MergedFinding[],
|
|
94
|
-
nameOf: WatchdogNameResolver,
|
|
95
|
-
): string[] {
|
|
96
|
-
const relevant = findings
|
|
97
|
-
.filter(
|
|
98
|
-
(finding) =>
|
|
99
|
-
finding.severity === "blocking" || finding.severity === "attention",
|
|
100
|
-
)
|
|
101
|
-
.slice(0, 5);
|
|
87
|
+
/** Pontos técnicos estruturais (módulos, contratos, impacto) — não achados. */
|
|
88
|
+
function technicalSection(summary: TechnicalSummary | null | undefined): string[] {
|
|
102
89
|
const lines = ["## Pontos técnicos", ""];
|
|
103
|
-
if (
|
|
104
|
-
lines.push("
|
|
90
|
+
if (!summary || (!summary.overview && summary.highlights.length === 0)) {
|
|
91
|
+
lines.push("Sem mudanças estruturais detectadas nesta revisão.", "");
|
|
105
92
|
return lines;
|
|
106
93
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
lines.push(
|
|
111
|
-
`- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
|
|
112
|
-
);
|
|
94
|
+
if (summary.overview) lines.push(summary.overview, "");
|
|
95
|
+
for (const highlight of summary.highlights) {
|
|
96
|
+
lines.push(`- **${highlight.area}** — ${truncate(highlight.detail, 300)}`);
|
|
113
97
|
}
|
|
114
98
|
lines.push("");
|
|
115
99
|
return lines;
|
|
@@ -125,10 +109,9 @@ function clamp(markdown: string, max: number): string {
|
|
|
125
109
|
|
|
126
110
|
/**
|
|
127
111
|
* Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
|
|
128
|
-
* nasce do contexto já disponível e dos
|
|
112
|
+
* nasce do contexto já disponível e dos pontos técnicos persistidos.
|
|
129
113
|
*/
|
|
130
114
|
export function renderReviewResume(input: ReviewResumeInput): string {
|
|
131
|
-
const nameOf = input.nameOf ?? identityName;
|
|
132
115
|
const blockers = input.findings.filter((f) => f.severity === "blocking").length;
|
|
133
116
|
const attention = input.findings.filter((f) => f.severity === "attention").length;
|
|
134
117
|
const status =
|
|
@@ -144,7 +127,7 @@ export function renderReviewResume(input: ReviewResumeInput): string {
|
|
|
144
127
|
`**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
|
|
145
128
|
"",
|
|
146
129
|
...businessSection(input),
|
|
147
|
-
...technicalSection(input.
|
|
130
|
+
...technicalSection(input.technical),
|
|
148
131
|
];
|
|
149
132
|
|
|
150
133
|
return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
|
|
@@ -63,14 +63,14 @@ export type ContextSummary = {
|
|
|
63
63
|
const VALID_SEVERITIES = new Set<string>(SEVERITY_ORDER);
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Limites do resumo da PR:
|
|
67
|
-
*
|
|
66
|
+
* Limites do resumo da PR: o contexto abre o relatório e o resumo final, mas
|
|
67
|
+
* não pode inflar os briefs — a injeção nos briefs é recortada em `brief.ts`.
|
|
68
68
|
*/
|
|
69
69
|
const CONTEXT_LIMITS = {
|
|
70
|
-
objective:
|
|
71
|
-
motivation:
|
|
72
|
-
changeSummaryItem:
|
|
73
|
-
changeSummaryItems:
|
|
70
|
+
objective: 400,
|
|
71
|
+
motivation: 480,
|
|
72
|
+
changeSummaryItem: 240,
|
|
73
|
+
changeSummaryItems: 6,
|
|
74
74
|
} as const;
|
|
75
75
|
|
|
76
76
|
/** Corta `value` em `max` caracteres, sinalizando com reticências. */
|
|
@@ -75,6 +75,52 @@ export class GitRepository {
|
|
|
75
75
|
isAncestor(ancestor: string, descendant: string): boolean {
|
|
76
76
|
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
77
77
|
}
|
|
78
|
+
/** SHA do merge-base entre duas refs; `null` quando não há ancestral comum. */
|
|
79
|
+
mergeBase(base: string, head: string): string | null {
|
|
80
|
+
const result = this.tryRun(["merge-base", base, head]);
|
|
81
|
+
const sha = result.ok ? result.stdout.trim() : "";
|
|
82
|
+
return sha.length > 0 ? sha : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Diretório git comum (`.git`) em caminho absoluto. Em um worktree linkado
|
|
87
|
+
* aponta para o `.git` do clone principal — é como descobrimos a raiz real.
|
|
88
|
+
*/
|
|
89
|
+
gitCommonDir(): string | null {
|
|
90
|
+
const result = this.tryRun([
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"--path-format=absolute",
|
|
93
|
+
"--git-common-dir",
|
|
94
|
+
]);
|
|
95
|
+
const dir = result.ok ? result.stdout.trim() : "";
|
|
96
|
+
return dir.length > 0 ? dir : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Saída crua de `git worktree list --porcelain` (vazia em erro). */
|
|
100
|
+
worktreeListRaw(): string {
|
|
101
|
+
const result = this.tryRun(["worktree", "list", "--porcelain"]);
|
|
102
|
+
return result.ok ? result.stdout : "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Cria um worktree detached apontando para `ref` (lança em falha). */
|
|
106
|
+
addWorktree(path: string, ref: string): string {
|
|
107
|
+
return this.run(["worktree", "add", "--detach", path, ref]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove um worktree; nunca lança (devolve o resultado para o chamador). */
|
|
111
|
+
removeWorktree(path: string, force = false): GitResult {
|
|
112
|
+
return this.tryRun(["worktree", "remove", ...(force ? ["--force"] : []), path]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove metadados de worktrees cujo diretório sumiu. */
|
|
116
|
+
pruneWorktrees(): GitResult {
|
|
117
|
+
return this.tryRun(["worktree", "prune"]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `git fetch --no-tags <remote> <refspecs...>` (nunca lança). */
|
|
121
|
+
fetch(remote: string, refspecs: readonly string[]): GitResult {
|
|
122
|
+
return this.tryRun(["fetch", "--no-tags", remote, ...refspecs]);
|
|
123
|
+
}
|
|
78
124
|
|
|
79
125
|
/** Conteúdo de um arquivo num commit; `null` se ausente. */
|
|
80
126
|
readFileAt(sha: string, path: string): string | null {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolução de uma PR para revisão (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Usa `gh pr view` para descobrir head/base e garante os commits localmente.
|
|
5
|
+
* O head é buscado em `refs/pull/<n>/head`, que funciona também para forks;
|
|
6
|
+
* o base vem de `origin/<baseRefName>`. Fail-open na leitura, mas a revisão só
|
|
7
|
+
* começa quando as refs necessárias existem (erro explícito caso contrário).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describeGhError, type GhRunner } from "./gh-client";
|
|
11
|
+
import type { GitRepository } from "./git-repository";
|
|
12
|
+
|
|
13
|
+
const PR_FIELDS =
|
|
14
|
+
"number,title,headRefName,baseRefName,headRefOid,isCrossRepository,url,isDraft";
|
|
15
|
+
|
|
16
|
+
/** Dados mínimos de uma PR para montar o worktree. */
|
|
17
|
+
export type PullRequestInfo = {
|
|
18
|
+
number: number;
|
|
19
|
+
title?: string;
|
|
20
|
+
headRefName: string;
|
|
21
|
+
baseRefName: string;
|
|
22
|
+
/** Commit exato do head no momento da consulta. */
|
|
23
|
+
headSha?: string;
|
|
24
|
+
isCrossRepository: boolean;
|
|
25
|
+
isDraft: boolean;
|
|
26
|
+
url?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Resultado da consulta ao `gh`. */
|
|
30
|
+
export type PrResolveResult =
|
|
31
|
+
| { ok: true; pr: PullRequestInfo }
|
|
32
|
+
| { ok: false; reason: "gh" | "invalid"; detail: string };
|
|
33
|
+
|
|
34
|
+
/** Refs locais preparadas para o diff. */
|
|
35
|
+
export type PrRefs = {
|
|
36
|
+
base: string;
|
|
37
|
+
head: string;
|
|
38
|
+
/** `true` quando o fetch trouxe algo novo. */
|
|
39
|
+
fetched: boolean;
|
|
40
|
+
/** Aviso (ex.: fetch parcial) para o CLI imprimir. */
|
|
41
|
+
warning?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type GhPrView = {
|
|
45
|
+
number?: unknown;
|
|
46
|
+
title?: unknown;
|
|
47
|
+
headRefName?: unknown;
|
|
48
|
+
baseRefName?: unknown;
|
|
49
|
+
headRefOid?: unknown;
|
|
50
|
+
isCrossRepository?: unknown;
|
|
51
|
+
isDraft?: unknown;
|
|
52
|
+
url?: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Cliente de PR sobre um runner injetável. */
|
|
56
|
+
export class PrResolver {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly runner: GhRunner,
|
|
59
|
+
private readonly git: GitRepository,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
/** Consulta a PR no GitHub (nunca lança). */
|
|
63
|
+
resolve(number: number): PrResolveResult {
|
|
64
|
+
const result = this.runner(["pr", "view", String(number), "--json", PR_FIELDS]);
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
return { ok: false, reason: "gh", detail: describeGhError(result) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let parsed: GhPrView;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(result.stdout) as GhPrView;
|
|
72
|
+
} catch {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "invalid",
|
|
76
|
+
detail: "Resposta inválida do gh (JSON malformado).",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const headRefName =
|
|
81
|
+
typeof parsed.headRefName === "string" ? parsed.headRefName.trim() : "";
|
|
82
|
+
const baseRefName =
|
|
83
|
+
typeof parsed.baseRefName === "string" ? parsed.baseRefName.trim() : "";
|
|
84
|
+
const resolvedNumber = Number(parsed.number);
|
|
85
|
+
if (
|
|
86
|
+
!Number.isFinite(resolvedNumber) ||
|
|
87
|
+
resolvedNumber <= 0 ||
|
|
88
|
+
headRefName.length === 0 ||
|
|
89
|
+
baseRefName.length === 0
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: "invalid",
|
|
94
|
+
detail: "gh não devolveu número/head/base da PR.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
pr: {
|
|
101
|
+
number: resolvedNumber,
|
|
102
|
+
title: typeof parsed.title === "string" ? parsed.title : undefined,
|
|
103
|
+
headRefName,
|
|
104
|
+
baseRefName,
|
|
105
|
+
headSha:
|
|
106
|
+
typeof parsed.headRefOid === "string"
|
|
107
|
+
? parsed.headRefOid.trim()
|
|
108
|
+
: undefined,
|
|
109
|
+
isCrossRepository: Boolean(parsed.isCrossRepository),
|
|
110
|
+
isDraft: Boolean(parsed.isDraft),
|
|
111
|
+
url: typeof parsed.url === "string" ? parsed.url : undefined,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Garante base/head da PR no repositório local.
|
|
118
|
+
*
|
|
119
|
+
* Prefere o SHA informado pelo `gh`; cai para `refs/houndsman/pr-<n>` quando
|
|
120
|
+
* o objeto não existe localmente.
|
|
121
|
+
*/
|
|
122
|
+
prepareRefs(pr: PullRequestInfo): PrRefs {
|
|
123
|
+
const headRef = `refs/houndsman/pr-${pr.number}`;
|
|
124
|
+
const headFetch = this.git.fetch("origin", [
|
|
125
|
+
`+refs/pull/${pr.number}/head:${headRef}`,
|
|
126
|
+
]);
|
|
127
|
+
const baseFetch = this.git.fetch("origin", [pr.baseRefName]);
|
|
128
|
+
|
|
129
|
+
const originBase = `origin/${pr.baseRefName}`;
|
|
130
|
+
const hasOriginBase = this.git.tryRun(["rev-parse", "--verify", originBase]).ok;
|
|
131
|
+
const base = hasOriginBase ? originBase : pr.baseRefName;
|
|
132
|
+
|
|
133
|
+
const hasSha = pr.headSha
|
|
134
|
+
? this.git.tryRun(["cat-file", "-e", `${pr.headSha}^{commit}`]).ok
|
|
135
|
+
: false;
|
|
136
|
+
const head = hasSha && pr.headSha ? pr.headSha : headRef;
|
|
137
|
+
|
|
138
|
+
const warning =
|
|
139
|
+
!headFetch.ok && !hasSha
|
|
140
|
+
? `Não foi possível buscar o head da PR: ${headFetch.stderr.trim()}`
|
|
141
|
+
: !baseFetch.ok
|
|
142
|
+
? `Não foi possível atualizar ${pr.baseRefName}: ${baseFetch.stderr.trim()}`
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
145
|
+
return { base, head, fetched: headFetch.ok || baseFetch.ok, warning };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktrees gerenciados pelo houndsman (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Um worktree por branch revisada, criado em **detached HEAD** no commit da PR.
|
|
5
|
+
* Detached evita o conflito "branch já checada" com o clone principal e é o
|
|
6
|
+
* modo correto para revisão (somente leitura). Vive fora do projeto, em
|
|
7
|
+
* `<baseDir>/<repoSlug>/<branchSlug>`.
|
|
8
|
+
*
|
|
9
|
+
* Os dois segmentos são `<trecho-legível>-<hash8>`: o trecho legível é
|
|
10
|
+
* truncado porque o checkout de uma PR herda a profundidade dos arquivos do
|
|
11
|
+
* projeto, e o Windows (MAX_PATH = 260) estoura "Filename too long" com
|
|
12
|
+
* repo+branch longos. O hash mantém o nome determinístico e único.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
import { createGitRunner } from "../git-runner";
|
|
19
|
+
import { sanitizeBranch } from "../report-builder";
|
|
20
|
+
import { worktreesRoot } from "../storage/paths";
|
|
21
|
+
import { GitRepository } from "./git-repository";
|
|
22
|
+
|
|
23
|
+
/** Entrada de `git worktree list --porcelain`. */
|
|
24
|
+
export type WorktreeEntry = {
|
|
25
|
+
path: string;
|
|
26
|
+
head?: string;
|
|
27
|
+
/** Ref completo (`refs/heads/...`) quando não detached. */
|
|
28
|
+
branch?: string;
|
|
29
|
+
detached: boolean;
|
|
30
|
+
bare: boolean;
|
|
31
|
+
prunable: boolean;
|
|
32
|
+
locked: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Entrada de criação de worktree. */
|
|
36
|
+
export type EnsureWorktreeInput = {
|
|
37
|
+
branch: string;
|
|
38
|
+
/** Ref/SHA que o worktree deve apontar. */
|
|
39
|
+
ref: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Resultado da criação/reuso. */
|
|
43
|
+
export type EnsureWorktreeResult = {
|
|
44
|
+
path: string;
|
|
45
|
+
/** Commit efetivamente checado (detached). */
|
|
46
|
+
head: string;
|
|
47
|
+
/** `true` quando o diretório foi criado agora. */
|
|
48
|
+
created: boolean;
|
|
49
|
+
/** `true` quando já existia apontando para o commit pedido. */
|
|
50
|
+
reused: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Resultado da remoção. */
|
|
54
|
+
export type RemoveWorktreeResult = {
|
|
55
|
+
removed: boolean;
|
|
56
|
+
detail?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Opções do gerenciador. */
|
|
60
|
+
export type WorktreeManagerOptions = {
|
|
61
|
+
repoKey: string;
|
|
62
|
+
/** Raiz do clone principal. */
|
|
63
|
+
repoRoot: string;
|
|
64
|
+
/**
|
|
65
|
+
* Raiz onde os diretórios por repositório são criados.
|
|
66
|
+
* Default: `<HOUNDSMAN_HOME>/worktrees` (o CLI normalmente passa
|
|
67
|
+
* `resolveWorktreesRoot(config.worktrees?.location, repoRoot)`).
|
|
68
|
+
*/
|
|
69
|
+
baseDir?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Limite do trecho legível no diretório do repositório. */
|
|
73
|
+
const REPO_SLUG_MAX = 24;
|
|
74
|
+
/** Limite do trecho legível no diretório da branch. */
|
|
75
|
+
const BRANCH_SLUG_MAX = 20;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `<trecho legível truncado>-<hash8>`: curto o bastante para o MAX_PATH do
|
|
79
|
+
* Windows e estável para o mesmo par (repositório, branch).
|
|
80
|
+
*/
|
|
81
|
+
function hashName(key: string, readable: string, max: number, fallback: string): string {
|
|
82
|
+
const hash = createHash("sha1").update(key).digest("hex").slice(0, 8);
|
|
83
|
+
// Ponto/traço no fim do nome é descartado pelo Windows; corte antes do hash.
|
|
84
|
+
const head = readable.slice(0, max).replace(/[-._]+$/, "");
|
|
85
|
+
return `${head || fallback}-${hash}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Slug estável e legível para o diretório do repositório. */
|
|
89
|
+
export function repoSlug(repoKey: string): string {
|
|
90
|
+
const clean = repoKey
|
|
91
|
+
.replace(/^(remote|path):/, "")
|
|
92
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
93
|
+
.replace(/^-+|-+$/g, "")
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
return hashName(repoKey, clean.slice(-REPO_SLUG_MAX), REPO_SLUG_MAX, "repo");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Slug estável e curto para o diretório de uma branch. */
|
|
99
|
+
export function branchSlug(branch: string): string {
|
|
100
|
+
return hashName(branch, sanitizeBranch(branch), BRANCH_SLUG_MAX, "branch");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Normaliza um path para comparação (Windows é case-insensitive). */
|
|
104
|
+
function normalizePath(path: string): string {
|
|
105
|
+
const absolute = resolve(path).replace(/[\\/]+$/, "");
|
|
106
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `true` se `path` está dentro de `root`. */
|
|
110
|
+
function isInside(root: string, path: string): boolean {
|
|
111
|
+
const rel = relative(resolve(root), resolve(path));
|
|
112
|
+
return rel === "" || (!rel.startsWith("..") && !rel.split(sep).includes(".."));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Fatia a saída de `git worktree list --porcelain` em entradas. */
|
|
116
|
+
export function parseWorktreeList(raw: string): WorktreeEntry[] {
|
|
117
|
+
const entries: WorktreeEntry[] = [];
|
|
118
|
+
let current: WorktreeEntry | null = null;
|
|
119
|
+
|
|
120
|
+
const flush = (): void => {
|
|
121
|
+
if (current) entries.push(current);
|
|
122
|
+
current = null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const line of raw.split("\n")) {
|
|
126
|
+
if (line.startsWith("worktree ")) {
|
|
127
|
+
flush();
|
|
128
|
+
current = {
|
|
129
|
+
path: line.slice("worktree ".length).trim(),
|
|
130
|
+
detached: false,
|
|
131
|
+
bare: false,
|
|
132
|
+
prunable: false,
|
|
133
|
+
locked: false,
|
|
134
|
+
};
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!current) continue;
|
|
138
|
+
if (line.startsWith("HEAD ")) current.head = line.slice(5).trim();
|
|
139
|
+
else if (line.startsWith("branch ")) current.branch = line.slice(7).trim();
|
|
140
|
+
else if (line === "detached") current.detached = true;
|
|
141
|
+
else if (line === "bare") current.bare = true;
|
|
142
|
+
else if (line.startsWith("prunable")) current.prunable = true;
|
|
143
|
+
else if (line.startsWith("locked")) current.locked = true;
|
|
144
|
+
}
|
|
145
|
+
flush();
|
|
146
|
+
return entries;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Gerencia os worktrees de um repositório. */
|
|
150
|
+
export class WorktreeManager {
|
|
151
|
+
private readonly git: GitRepository;
|
|
152
|
+
private readonly repoKey: string;
|
|
153
|
+
private readonly rootDir: string;
|
|
154
|
+
|
|
155
|
+
constructor(git: GitRepository, options: WorktreeManagerOptions) {
|
|
156
|
+
this.git = git;
|
|
157
|
+
this.repoKey = options.repoKey;
|
|
158
|
+
this.rootDir = join(
|
|
159
|
+
options.baseDir ?? worktreesRoot(),
|
|
160
|
+
repoSlug(options.repoKey),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Garante o diretório gerenciado e um `.gitignore` local (`*`), para que
|
|
166
|
+
* worktrees dentro do repositório nunca sujem `git status` — mesmo que a
|
|
167
|
+
* camada `.houndsman/.gitignore` ainda não tenha sido atualizada.
|
|
168
|
+
*/
|
|
169
|
+
private ensureRoot(): void {
|
|
170
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
171
|
+
const ignore = join(this.rootDir, ".gitignore");
|
|
172
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Diretório-base dos worktrees deste repositório. */
|
|
176
|
+
get baseDir(): string {
|
|
177
|
+
return this.rootDir;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Caminho determinístico do worktree de uma branch. */
|
|
181
|
+
pathFor(branch: string): string {
|
|
182
|
+
return join(this.rootDir, branchSlug(branch));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Todos os worktrees registrados no repositório. */
|
|
186
|
+
list(): WorktreeEntry[] {
|
|
187
|
+
return parseWorktreeList(this.git.worktreeListRaw());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Apenas os worktrees sob o diretório gerenciado. */
|
|
191
|
+
managed(): WorktreeEntry[] {
|
|
192
|
+
return this.list().filter((entry) => isInside(this.rootDir, entry.path));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Worktree registrado para uma branch (se existir). */
|
|
196
|
+
find(branch: string): WorktreeEntry | null {
|
|
197
|
+
const target = normalizePath(this.pathFor(branch));
|
|
198
|
+
return this.list().find((entry) => normalizePath(entry.path) === target) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Garante o worktree da branch no commit `ref`.
|
|
203
|
+
*
|
|
204
|
+
* Reusa quando o HEAD já é o commit pedido; tenta `checkout --detach` quando
|
|
205
|
+
* existe e está em outro commit; recria quando o diretório está quebrado.
|
|
206
|
+
*/
|
|
207
|
+
ensure(input: EnsureWorktreeInput): EnsureWorktreeResult {
|
|
208
|
+
this.ensureRoot();
|
|
209
|
+
const path = this.pathFor(input.branch);
|
|
210
|
+
const target = this.git.resolveSha(input.ref);
|
|
211
|
+
const existing = this.list().find(
|
|
212
|
+
(entry) => normalizePath(entry.path) === normalizePath(path),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (existing) {
|
|
216
|
+
if (existing.head === target) {
|
|
217
|
+
return { path, head: target, created: false, reused: true };
|
|
218
|
+
}
|
|
219
|
+
const checkout = new GitRepository(createGitRunner(path)).tryRun([
|
|
220
|
+
"checkout",
|
|
221
|
+
"--detach",
|
|
222
|
+
target,
|
|
223
|
+
]);
|
|
224
|
+
if (checkout.ok) {
|
|
225
|
+
return { path, head: target, created: false, reused: true };
|
|
226
|
+
}
|
|
227
|
+
// Worktree quebrado/inacessível: remove e recria do zero.
|
|
228
|
+
this.git.removeWorktree(path, true);
|
|
229
|
+
rmSync(path, { recursive: true, force: true });
|
|
230
|
+
} else if (existsSync(path)) {
|
|
231
|
+
// Diretório sobrou de um prune/remoção manual.
|
|
232
|
+
if (!isInside(this.rootDir, path)) {
|
|
233
|
+
throw new Error(`Worktree fora da raiz gerenciada: ${path}`);
|
|
234
|
+
}
|
|
235
|
+
rmSync(path, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
239
|
+
try {
|
|
240
|
+
this.git.addWorktree(path, target);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Corrida com outro processo ou metadado órfão: pruna e tenta 1x.
|
|
243
|
+
this.git.pruneWorktrees();
|
|
244
|
+
try {
|
|
245
|
+
this.git.addWorktree(path, target);
|
|
246
|
+
} catch (retry) {
|
|
247
|
+
const detail = retry instanceof Error ? retry.message : String(retry);
|
|
248
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Falha ao criar worktree em ${path}: ${detail || original}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { path, head: target, created: true, reused: false };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Remove o worktree de uma branch (ou um path absoluto). */
|
|
258
|
+
remove(
|
|
259
|
+
branchOrPath: string,
|
|
260
|
+
options: { force?: boolean } = {},
|
|
261
|
+
): RemoveWorktreeResult {
|
|
262
|
+
const path = isAbsolute(branchOrPath) ? branchOrPath : this.pathFor(branchOrPath);
|
|
263
|
+
const entry = this.list().find(
|
|
264
|
+
(worktree) => normalizePath(worktree.path) === normalizePath(path),
|
|
265
|
+
);
|
|
266
|
+
if (!entry) {
|
|
267
|
+
if (existsSync(path) && isInside(this.rootDir, path)) {
|
|
268
|
+
rmSync(path, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
return { removed: false, detail: "worktree não registrado" };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const result = this.git.removeWorktree(path, options.force ?? false);
|
|
274
|
+
if (result.ok) return { removed: true };
|
|
275
|
+
|
|
276
|
+
if (options.force) {
|
|
277
|
+
rmSync(path, { recursive: true, force: true });
|
|
278
|
+
this.git.pruneWorktrees();
|
|
279
|
+
return { removed: true };
|
|
280
|
+
}
|
|
281
|
+
return { removed: false, detail: result.stderr.trim() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Limpa metadados de worktrees que não existem mais. */
|
|
285
|
+
prune(): string {
|
|
286
|
+
return this.git.pruneWorktrees().stdout.trim();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Nome legível do repositório gerenciado (diagnóstico). */
|
|
290
|
+
get name(): string {
|
|
291
|
+
return basename(this.rootDir) || this.repoKey;
|
|
292
|
+
}
|
|
293
|
+
}
|