@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.
Files changed (88) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ai/ai-client.ts +21 -0
  11. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  12. package/src/core/ai/features/technical-summarizer.ts +43 -0
  13. package/src/core/ai/index.ts +4 -1
  14. package/src/core/ai/prompts/context.ts +3 -3
  15. package/src/core/ai/prompts/technical.ts +30 -0
  16. package/src/core/ai/provider-capabilities.ts +45 -0
  17. package/src/core/ai/schemas.ts +19 -3
  18. package/src/core/ai/types.ts +3 -1
  19. package/src/core/ast/service.ts +7 -2
  20. package/src/core/brief.ts +26 -4
  21. package/src/core/commands/context.ts +9 -1
  22. package/src/core/commands/findings.ts +11 -5
  23. package/src/core/commands/review.ts +13 -0
  24. package/src/core/commands/scaffold.ts +9 -2
  25. package/src/core/config.ts +30 -1
  26. package/src/core/defaults.ts +7 -0
  27. package/src/core/engine/houndsman-engine.ts +26 -3
  28. package/src/core/engine/review-scope.ts +17 -22
  29. package/src/core/engine/review-session.ts +25 -2
  30. package/src/core/engine/review-workspace.ts +204 -0
  31. package/src/core/git-runner.ts +8 -1
  32. package/src/core/jev/jev-detector.ts +10 -2
  33. package/src/core/orchestrator.ts +176 -15
  34. package/src/core/paths.ts +3 -0
  35. package/src/core/pr-context.ts +11 -6
  36. package/src/core/project-config.ts +21 -1
  37. package/src/core/renderers/resume-renderer.ts +17 -34
  38. package/src/core/report-builder.ts +6 -6
  39. package/src/core/services/git-repository.ts +46 -0
  40. package/src/core/services/pr-resolver.ts +147 -0
  41. package/src/core/services/worktree-manager.ts +293 -0
  42. package/src/core/storage/file/file-storage.ts +14 -1
  43. package/src/core/storage/paths.ts +30 -1
  44. package/src/core/storage/sqlite/mappers.ts +4 -0
  45. package/src/core/storage/sqlite/schema.ts +13 -3
  46. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  47. package/src/core/storage/types.ts +13 -0
  48. package/src/core/storage/work-area.ts +20 -1
  49. package/src/core/technical-summary.ts +398 -0
  50. package/src/core/types.ts +2 -0
  51. package/src/harness/agent/agent-factory.ts +1 -0
  52. package/src/harness/agent/review-agent.ts +11 -1
  53. package/src/harness/config/harness-config.ts +6 -1
  54. package/src/harness/hunt/hunt-options.ts +17 -0
  55. package/src/harness/hunt/hunt-session.ts +7 -20
  56. package/src/index.ts +33 -0
  57. package/src/server/client/App.tsx +51 -12
  58. package/src/server/client/DeleteRunModal.tsx +163 -0
  59. package/src/server/client/PrsSection.tsx +296 -0
  60. package/src/server/client/ResumePanel.tsx +17 -20
  61. package/src/server/client/RunsSection.tsx +73 -0
  62. package/src/server/client/Sidebar.tsx +171 -358
  63. package/src/server/client/icons.tsx +10 -0
  64. package/src/server/client/sidebar-sections.tsx +73 -0
  65. package/src/server/client/utils/constants.ts +7 -2
  66. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  67. package/src/server/client/utils/types.ts +27 -0
  68. package/src/server/index.ts +113 -406
  69. package/src/server/pr-inbox.ts +15 -4
  70. package/src/server/run-repository.ts +103 -2
  71. package/src/server/styles.css +454 -0
  72. package/src/skill/README.md +11 -3
  73. package/src/skill/SKILL.md +44 -10
  74. package/src/skill/hunt.md +33 -7
  75. package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
  76. package/src/tests/ai-core.test.ts +71 -0
  77. package/src/tests/ai-features.test.ts +36 -0
  78. package/src/tests/baseline.test.ts +4 -1
  79. package/src/tests/cli.test.ts +2 -1
  80. package/src/tests/harness-agent.test.ts +19 -0
  81. package/src/tests/review-resume.test.ts +36 -8
  82. package/src/tests/review-scope.test.ts +67 -0
  83. package/src/tests/review-scripts.test.ts +2 -2
  84. package/src/tests/server.test.ts +54 -1
  85. package/src/tests/sidebar-sections.test.ts +94 -0
  86. package/src/tests/technical-summary.test.ts +176 -0
  87. package/src/tests/worktree.test.ts +404 -0
  88. 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 números técnicos diretos da revisão, com limite de
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 = 2500;
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
- nameOf?: WatchdogNameResolver;
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, 4)) {
84
- lines.push(`- ${truncate(item, 180)}`);
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
- /** Achados que exigem ação, em formato direto (máximo 5). */
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 (relevant.length === 0) {
104
- lines.push("- Nenhum bloqueio ou ponto de atenção.", "");
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
- for (const finding of relevant) {
108
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
109
- const watchdogs = finding.watchdogs.map((id) => nameOf(id)).join(", ");
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 findings consolidados.
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.findings, nameOf),
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: mantém o relatório e os briefs enxutos — evita
67
- * confundir o watchdog humano e estourar o contexto da IA watchdog.
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: 200,
71
- motivation: 240,
72
- changeSummaryItem: 160,
73
- changeSummaryItems: 4,
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
+ }