@payconductor/houndsman 0.7.0 → 0.8.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/ast/service.ts +7 -2
- package/src/core/brief.ts +1 -0
- 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 +4 -2
- package/src/core/config.ts +13 -0
- 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/jev/jev-detector.ts +10 -2
- package/src/core/orchestrator.ts +62 -13
- package/src/core/project-config.ts +12 -0
- 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 +269 -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 +12 -0
- package/src/core/storage/work-area.ts +20 -1
- package/src/core/types.ts +2 -0
- package/src/harness/hunt/hunt-options.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +7 -20
- package/src/index.ts +31 -0
- package/src/server/client/App.tsx +49 -11
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/ResumePanel.tsx +8 -8
- package/src/server/client/Sidebar.tsx +56 -1
- package/src/server/client/icons.tsx +10 -0
- package/src/server/client/utils/types.ts +26 -0
- package/src/server/index.ts +97 -4
- package/src/server/pr-inbox.ts +15 -4
- package/src/server/run-repository.ts +103 -2
- 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 +94 -24
- package/src/tests/cli.test.ts +2 -1
- package/src/tests/server.test.ts +38 -0
- package/src/tests/worktree.test.ts +369 -0
|
@@ -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,269 @@
|
|
|
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
|
+
* `<HOUNDSMAN_HOME>/worktrees/<repoSlug>/<branchSlug>`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { createGitRunner } from "../git-runner";
|
|
14
|
+
import { sanitizeBranch } from "../report-builder";
|
|
15
|
+
import { worktreesRoot } from "../storage/paths";
|
|
16
|
+
import { GitRepository } from "./git-repository";
|
|
17
|
+
|
|
18
|
+
/** Entrada de `git worktree list --porcelain`. */
|
|
19
|
+
export type WorktreeEntry = {
|
|
20
|
+
path: string;
|
|
21
|
+
head?: string;
|
|
22
|
+
/** Ref completo (`refs/heads/...`) quando não detached. */
|
|
23
|
+
branch?: string;
|
|
24
|
+
detached: boolean;
|
|
25
|
+
bare: boolean;
|
|
26
|
+
prunable: boolean;
|
|
27
|
+
locked: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Entrada de criação de worktree. */
|
|
31
|
+
export type EnsureWorktreeInput = {
|
|
32
|
+
branch: string;
|
|
33
|
+
/** Ref/SHA que o worktree deve apontar. */
|
|
34
|
+
ref: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Resultado da criação/reuso. */
|
|
38
|
+
export type EnsureWorktreeResult = {
|
|
39
|
+
path: string;
|
|
40
|
+
/** Commit efetivamente checado (detached). */
|
|
41
|
+
head: string;
|
|
42
|
+
/** `true` quando o diretório foi criado agora. */
|
|
43
|
+
created: boolean;
|
|
44
|
+
/** `true` quando já existia apontando para o commit pedido. */
|
|
45
|
+
reused: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Resultado da remoção. */
|
|
49
|
+
export type RemoveWorktreeResult = {
|
|
50
|
+
removed: boolean;
|
|
51
|
+
detail?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Opções do gerenciador. */
|
|
55
|
+
export type WorktreeManagerOptions = {
|
|
56
|
+
repoKey: string;
|
|
57
|
+
/** Raiz do clone principal. */
|
|
58
|
+
repoRoot: string;
|
|
59
|
+
/**
|
|
60
|
+
* Raiz onde os diretórios por repositório são criados.
|
|
61
|
+
* Default: `<HOUNDSMAN_HOME>/worktrees` (o CLI normalmente passa
|
|
62
|
+
* `resolveWorktreesRoot(config.worktrees?.location, repoRoot)`).
|
|
63
|
+
*/
|
|
64
|
+
baseDir?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Slug estável e legível para o diretório do repositório. */
|
|
68
|
+
export function repoSlug(repoKey: string): string {
|
|
69
|
+
const clean = repoKey
|
|
70
|
+
.replace(/^(remote|path):/, "")
|
|
71
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
72
|
+
.replace(/^-+|-+$/g, "")
|
|
73
|
+
.toLowerCase();
|
|
74
|
+
const hash = createHash("sha1").update(repoKey).digest("hex").slice(0, 8);
|
|
75
|
+
return `${clean.slice(-60) || "repo"}-${hash}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Normaliza um path para comparação (Windows é case-insensitive). */
|
|
79
|
+
function normalizePath(path: string): string {
|
|
80
|
+
const absolute = resolve(path).replace(/[\\/]+$/, "");
|
|
81
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `true` se `path` está dentro de `root`. */
|
|
85
|
+
function isInside(root: string, path: string): boolean {
|
|
86
|
+
const rel = relative(resolve(root), resolve(path));
|
|
87
|
+
return rel === "" || (!rel.startsWith("..") && !rel.split(sep).includes(".."));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fatia a saída de `git worktree list --porcelain` em entradas. */
|
|
91
|
+
export function parseWorktreeList(raw: string): WorktreeEntry[] {
|
|
92
|
+
const entries: WorktreeEntry[] = [];
|
|
93
|
+
let current: WorktreeEntry | null = null;
|
|
94
|
+
|
|
95
|
+
const flush = (): void => {
|
|
96
|
+
if (current) entries.push(current);
|
|
97
|
+
current = null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const line of raw.split("\n")) {
|
|
101
|
+
if (line.startsWith("worktree ")) {
|
|
102
|
+
flush();
|
|
103
|
+
current = {
|
|
104
|
+
path: line.slice("worktree ".length).trim(),
|
|
105
|
+
detached: false,
|
|
106
|
+
bare: false,
|
|
107
|
+
prunable: false,
|
|
108
|
+
locked: false,
|
|
109
|
+
};
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!current) continue;
|
|
113
|
+
if (line.startsWith("HEAD ")) current.head = line.slice(5).trim();
|
|
114
|
+
else if (line.startsWith("branch ")) current.branch = line.slice(7).trim();
|
|
115
|
+
else if (line === "detached") current.detached = true;
|
|
116
|
+
else if (line === "bare") current.bare = true;
|
|
117
|
+
else if (line.startsWith("prunable")) current.prunable = true;
|
|
118
|
+
else if (line.startsWith("locked")) current.locked = true;
|
|
119
|
+
}
|
|
120
|
+
flush();
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Gerencia os worktrees de um repositório. */
|
|
125
|
+
export class WorktreeManager {
|
|
126
|
+
private readonly git: GitRepository;
|
|
127
|
+
private readonly repoKey: string;
|
|
128
|
+
private readonly rootDir: string;
|
|
129
|
+
|
|
130
|
+
constructor(git: GitRepository, options: WorktreeManagerOptions) {
|
|
131
|
+
this.git = git;
|
|
132
|
+
this.repoKey = options.repoKey;
|
|
133
|
+
this.rootDir = join(
|
|
134
|
+
options.baseDir ?? worktreesRoot(),
|
|
135
|
+
repoSlug(options.repoKey),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Garante o diretório gerenciado e um `.gitignore` local (`*`), para que
|
|
141
|
+
* worktrees dentro do repositório nunca sujem `git status` — mesmo que a
|
|
142
|
+
* camada `.houndsman/.gitignore` ainda não tenha sido atualizada.
|
|
143
|
+
*/
|
|
144
|
+
private ensureRoot(): void {
|
|
145
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
146
|
+
const ignore = join(this.rootDir, ".gitignore");
|
|
147
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Diretório-base dos worktrees deste repositório. */
|
|
151
|
+
get baseDir(): string {
|
|
152
|
+
return this.rootDir;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Caminho determinístico do worktree de uma branch. */
|
|
156
|
+
pathFor(branch: string): string {
|
|
157
|
+
const slug = sanitizeBranch(branch) || "branch";
|
|
158
|
+
return join(this.rootDir, slug);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Todos os worktrees registrados no repositório. */
|
|
162
|
+
list(): WorktreeEntry[] {
|
|
163
|
+
return parseWorktreeList(this.git.worktreeListRaw());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Apenas os worktrees sob o diretório gerenciado. */
|
|
167
|
+
managed(): WorktreeEntry[] {
|
|
168
|
+
return this.list().filter((entry) => isInside(this.rootDir, entry.path));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Worktree registrado para uma branch (se existir). */
|
|
172
|
+
find(branch: string): WorktreeEntry | null {
|
|
173
|
+
const target = normalizePath(this.pathFor(branch));
|
|
174
|
+
return this.list().find((entry) => normalizePath(entry.path) === target) ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Garante o worktree da branch no commit `ref`.
|
|
179
|
+
*
|
|
180
|
+
* Reusa quando o HEAD já é o commit pedido; tenta `checkout --detach` quando
|
|
181
|
+
* existe e está em outro commit; recria quando o diretório está quebrado.
|
|
182
|
+
*/
|
|
183
|
+
ensure(input: EnsureWorktreeInput): EnsureWorktreeResult {
|
|
184
|
+
this.ensureRoot();
|
|
185
|
+
const path = this.pathFor(input.branch);
|
|
186
|
+
const target = this.git.resolveSha(input.ref);
|
|
187
|
+
const existing = this.list().find(
|
|
188
|
+
(entry) => normalizePath(entry.path) === normalizePath(path),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (existing) {
|
|
192
|
+
if (existing.head === target) {
|
|
193
|
+
return { path, head: target, created: false, reused: true };
|
|
194
|
+
}
|
|
195
|
+
const checkout = new GitRepository(createGitRunner(path)).tryRun([
|
|
196
|
+
"checkout",
|
|
197
|
+
"--detach",
|
|
198
|
+
target,
|
|
199
|
+
]);
|
|
200
|
+
if (checkout.ok) {
|
|
201
|
+
return { path, head: target, created: false, reused: true };
|
|
202
|
+
}
|
|
203
|
+
// Worktree quebrado/inacessível: remove e recria do zero.
|
|
204
|
+
this.git.removeWorktree(path, true);
|
|
205
|
+
rmSync(path, { recursive: true, force: true });
|
|
206
|
+
} else if (existsSync(path)) {
|
|
207
|
+
// Diretório sobrou de um prune/remoção manual.
|
|
208
|
+
if (!isInside(this.rootDir, path)) {
|
|
209
|
+
throw new Error(`Worktree fora da raiz gerenciada: ${path}`);
|
|
210
|
+
}
|
|
211
|
+
rmSync(path, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
215
|
+
try {
|
|
216
|
+
this.git.addWorktree(path, target);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// Corrida com outro processo ou metadado órfão: pruna e tenta 1x.
|
|
219
|
+
this.git.pruneWorktrees();
|
|
220
|
+
try {
|
|
221
|
+
this.git.addWorktree(path, target);
|
|
222
|
+
} catch (retry) {
|
|
223
|
+
const detail = retry instanceof Error ? retry.message : String(retry);
|
|
224
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Falha ao criar worktree em ${path}: ${detail || original}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { path, head: target, created: true, reused: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Remove o worktree de uma branch (ou um path absoluto). */
|
|
234
|
+
remove(
|
|
235
|
+
branchOrPath: string,
|
|
236
|
+
options: { force?: boolean } = {},
|
|
237
|
+
): RemoveWorktreeResult {
|
|
238
|
+
const path = isAbsolute(branchOrPath) ? branchOrPath : this.pathFor(branchOrPath);
|
|
239
|
+
const entry = this.list().find(
|
|
240
|
+
(worktree) => normalizePath(worktree.path) === normalizePath(path),
|
|
241
|
+
);
|
|
242
|
+
if (!entry) {
|
|
243
|
+
if (existsSync(path) && isInside(this.rootDir, path)) {
|
|
244
|
+
rmSync(path, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
return { removed: false, detail: "worktree não registrado" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const result = this.git.removeWorktree(path, options.force ?? false);
|
|
250
|
+
if (result.ok) return { removed: true };
|
|
251
|
+
|
|
252
|
+
if (options.force) {
|
|
253
|
+
rmSync(path, { recursive: true, force: true });
|
|
254
|
+
this.git.pruneWorktrees();
|
|
255
|
+
return { removed: true };
|
|
256
|
+
}
|
|
257
|
+
return { removed: false, detail: result.stderr.trim() };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Limpa metadados de worktrees que não existem mais. */
|
|
261
|
+
prune(): string {
|
|
262
|
+
return this.git.pruneWorktrees().stdout.trim();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Nome legível do repositório gerenciado (diagnóstico). */
|
|
266
|
+
get name(): string {
|
|
267
|
+
return basename(this.rootDir) || this.repoKey;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -43,12 +43,17 @@ export class FileStorageProvider implements StorageProvider {
|
|
|
43
43
|
if (existing) {
|
|
44
44
|
if (
|
|
45
45
|
existing.repoRoot !== input.repo.root ||
|
|
46
|
-
existing.repoRemote !== input.repo.remote
|
|
46
|
+
existing.repoRemote !== input.repo.remote ||
|
|
47
|
+
(input.worktreePath !== undefined &&
|
|
48
|
+
existing.worktreePath !== input.worktreePath) ||
|
|
49
|
+
(input.prNumber !== undefined && existing.prNumber !== input.prNumber)
|
|
47
50
|
) {
|
|
48
51
|
return this.writeReview({
|
|
49
52
|
...existing,
|
|
50
53
|
repoRoot: input.repo.root,
|
|
51
54
|
repoRemote: input.repo.remote,
|
|
55
|
+
worktreePath: input.worktreePath ?? existing.worktreePath,
|
|
56
|
+
prNumber: input.prNumber ?? existing.prNumber,
|
|
52
57
|
});
|
|
53
58
|
}
|
|
54
59
|
return existing;
|
|
@@ -64,6 +69,8 @@ export class FileStorageProvider implements StorageProvider {
|
|
|
64
69
|
slug: input.slug,
|
|
65
70
|
base: input.base,
|
|
66
71
|
head: input.head,
|
|
72
|
+
worktreePath: input.worktreePath,
|
|
73
|
+
prNumber: input.prNumber,
|
|
67
74
|
status: null,
|
|
68
75
|
createdAt: now,
|
|
69
76
|
updatedAt: now,
|
|
@@ -99,11 +106,17 @@ export class FileStorageProvider implements StorageProvider {
|
|
|
99
106
|
if (patch.headSha !== undefined) updated.headSha = patch.headSha;
|
|
100
107
|
if (patch.reviewedSha !== undefined) updated.reviewedSha = patch.reviewedSha;
|
|
101
108
|
if (patch.reviewedAt !== undefined) updated.reviewedAt = patch.reviewedAt;
|
|
109
|
+
if (patch.worktreePath !== undefined) updated.worktreePath = patch.worktreePath;
|
|
110
|
+
if (patch.prNumber !== undefined) updated.prNumber = patch.prNumber;
|
|
102
111
|
if (patch.status !== undefined) updated.status = patch.status;
|
|
103
112
|
if (patch.resetStartedAt) updated.startedAt = now;
|
|
104
113
|
else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
|
|
105
114
|
if (patch.resetHeartbeat) updated.heartbeatAt = undefined;
|
|
106
115
|
else if (patch.heartbeatAt !== undefined) updated.heartbeatAt = patch.heartbeatAt;
|
|
116
|
+
if (patch.resetWorkspace) {
|
|
117
|
+
updated.worktreePath = undefined;
|
|
118
|
+
updated.prNumber = undefined;
|
|
119
|
+
}
|
|
107
120
|
updated.updatedAt = patch.updatedAt ?? now;
|
|
108
121
|
return this.writeReview(updated);
|
|
109
122
|
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { join, resolve } from "node:path";
|
|
10
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
11
11
|
import type { HoundsmanConfig } from "../config";
|
|
12
|
+
import { DEFAULT_WORKTREES_LOCATION } from "../defaults";
|
|
12
13
|
import { ENV_NAMES, env } from "../env";
|
|
14
|
+
import { PROJECT_DIR } from "../paths";
|
|
13
15
|
|
|
14
16
|
export const HOUNDSMAN_HOME_ENV = ENV_NAMES.home;
|
|
15
17
|
|
|
@@ -52,6 +54,33 @@ export function workRoot(): string {
|
|
|
52
54
|
return join(houndsmanHome(), "work");
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** Raiz global dos worktrees (`worktrees.location: "home"`). */
|
|
58
|
+
export function worktreesRoot(): string {
|
|
59
|
+
return join(houndsmanHome(), "worktrees");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Diretório de um worktree gerenciado. */
|
|
63
|
+
export function worktreeDir(repoSlug: string, branchSlug: string): string {
|
|
64
|
+
return join(worktreesRoot(), repoSlug, branchSlug);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Raiz efetiva dos worktrees de um repositório.
|
|
69
|
+
*
|
|
70
|
+
* `workspace` (default) mantém tudo em `<repo>/.houndsman/worktrees` — dentro
|
|
71
|
+
* da pasta aberta no editor, o que evita prompts de permissão de agentes.
|
|
72
|
+
* `home` usa o diretório global; outros valores são caminhos do usuário.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveWorktreesRoot(
|
|
75
|
+
location: string | undefined,
|
|
76
|
+
repoRoot: string,
|
|
77
|
+
): string {
|
|
78
|
+
const value = location?.trim() || DEFAULT_WORKTREES_LOCATION;
|
|
79
|
+
if (value === "home") return worktreesRoot();
|
|
80
|
+
if (value === "workspace") return join(repoRoot, PROJECT_DIR, "worktrees");
|
|
81
|
+
return isAbsolute(value) ? value : resolve(repoRoot, value);
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
/** Referência legível do storage configurado (diagnóstico). */
|
|
56
85
|
export function storageLocation(config: HoundsmanConfig): string {
|
|
57
86
|
return config.storage?.provider === "file"
|
|
@@ -31,6 +31,8 @@ export type ReviewRow = {
|
|
|
31
31
|
status: string | null;
|
|
32
32
|
started_at: string | null;
|
|
33
33
|
heartbeat_at: string | null;
|
|
34
|
+
worktree_path: string | null;
|
|
35
|
+
pr_number: number | null;
|
|
34
36
|
created_at: string;
|
|
35
37
|
updated_at: string;
|
|
36
38
|
};
|
|
@@ -95,6 +97,8 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
|
|
|
95
97
|
headSha: row.head_sha ?? undefined,
|
|
96
98
|
reviewedSha: row.reviewed_sha ?? undefined,
|
|
97
99
|
reviewedAt: row.reviewed_at ?? undefined,
|
|
100
|
+
worktreePath: row.worktree_path ?? undefined,
|
|
101
|
+
prNumber: row.pr_number ?? undefined,
|
|
98
102
|
status: (row.status as ReviewRecord["status"]) ?? null,
|
|
99
103
|
startedAt: row.started_at ?? undefined,
|
|
100
104
|
heartbeatAt: row.heartbeat_at ?? undefined,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Database } from "bun:sqlite";
|
|
9
9
|
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 3;
|
|
11
11
|
|
|
12
12
|
const DDL = `
|
|
13
13
|
CREATE TABLE IF NOT EXISTS reviews (
|
|
@@ -25,6 +25,8 @@ CREATE TABLE IF NOT EXISTS reviews (
|
|
|
25
25
|
status TEXT,
|
|
26
26
|
started_at TEXT,
|
|
27
27
|
heartbeat_at TEXT,
|
|
28
|
+
worktree_path TEXT,
|
|
29
|
+
pr_number INTEGER,
|
|
28
30
|
created_at TEXT NOT NULL,
|
|
29
31
|
updated_at TEXT NOT NULL,
|
|
30
32
|
UNIQUE(repo_key, branch)
|
|
@@ -105,11 +107,19 @@ export function migrate(db: Database, path: string): void {
|
|
|
105
107
|
db.transaction(() => {
|
|
106
108
|
db.exec(DDL);
|
|
107
109
|
// v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
|
|
110
|
+
// v3: worktree/pr da revisão (revisões paralelas).
|
|
108
111
|
const columns = db.query("PRAGMA table_info(reviews)").all() as {
|
|
109
112
|
name: string;
|
|
110
113
|
}[];
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
const missing: [string, string][] = [
|
|
115
|
+
["heartbeat_at", "TEXT"],
|
|
116
|
+
["worktree_path", "TEXT"],
|
|
117
|
+
["pr_number", "INTEGER"],
|
|
118
|
+
];
|
|
119
|
+
for (const [name, type] of missing) {
|
|
120
|
+
if (!columns.some((column) => column.name === name)) {
|
|
121
|
+
db.exec(`ALTER TABLE reviews ADD COLUMN ${name} ${type}`);
|
|
122
|
+
}
|
|
113
123
|
}
|
|
114
124
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
115
125
|
})();
|