mintree 0.1.2
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 +188 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +849 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +327 -0
- package/dist/commands/helpers/index.d.ts +1 -0
- package/dist/commands/helpers/index.js +1 -0
- package/dist/commands/helpers/session-signal/end.d.ts +2 -0
- package/dist/commands/helpers/session-signal/end.js +9 -0
- package/dist/commands/helpers/session-signal/index.d.ts +1 -0
- package/dist/commands/helpers/session-signal/index.js +1 -0
- package/dist/commands/helpers/session-signal/install.d.ts +2 -0
- package/dist/commands/helpers/session-signal/install.js +25 -0
- package/dist/commands/helpers/session-signal/notification.d.ts +2 -0
- package/dist/commands/helpers/session-signal/notification.js +9 -0
- package/dist/commands/helpers/session-signal/prompt.d.ts +2 -0
- package/dist/commands/helpers/session-signal/prompt.js +9 -0
- package/dist/commands/helpers/session-signal/stop.d.ts +2 -0
- package/dist/commands/helpers/session-signal/stop.js +9 -0
- package/dist/commands/helpers/shell-init.d.ts +11 -0
- package/dist/commands/helpers/shell-init.js +111 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +6 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/worktree/clean.d.ts +11 -0
- package/dist/commands/worktree/clean.js +206 -0
- package/dist/commands/worktree/create.d.ts +18 -0
- package/dist/commands/worktree/create.js +93 -0
- package/dist/commands/worktree/index.d.ts +1 -0
- package/dist/commands/worktree/index.js +1 -0
- package/dist/commands/worktree/list.d.ts +10 -0
- package/dist/commands/worktree/list.js +143 -0
- package/dist/commands/worktree/remove.d.ts +12 -0
- package/dist/commands/worktree/remove.js +46 -0
- package/dist/commands/worktree/work.d.ts +15 -0
- package/dist/commands/worktree/work.js +192 -0
- package/dist/lib/branch.d.ts +26 -0
- package/dist/lib/branch.js +57 -0
- package/dist/lib/claude.d.ts +26 -0
- package/dist/lib/claude.js +67 -0
- package/dist/lib/dashboard.d.ts +50 -0
- package/dist/lib/dashboard.js +139 -0
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +15 -0
- package/dist/lib/git.d.ts +110 -0
- package/dist/lib/git.js +320 -0
- package/dist/lib/github.d.ts +7 -0
- package/dist/lib/github.js +15 -0
- package/dist/lib/markers.d.ts +21 -0
- package/dist/lib/markers.js +43 -0
- package/dist/lib/metadata.d.ts +18 -0
- package/dist/lib/metadata.js +44 -0
- package/dist/lib/session-signal.d.ts +63 -0
- package/dist/lib/session-signal.js +160 -0
- package/dist/lib/worktreeCreate.d.ts +36 -0
- package/dist/lib/worktreeCreate.js +184 -0
- package/dist/lib/worktreeRemove.d.ts +21 -0
- package/dist/lib/worktreeRemove.js +84 -0
- package/package.json +63 -0
- package/shell/init.bash +106 -0
- package/shell/init.zsh +125 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the absolute path of the **main** git repo root (not a worktree's
|
|
3
|
+
* checkout). When invoked from inside a linked worktree, `git rev-parse
|
|
4
|
+
* --show-toplevel` would return the worktree path; we resolve the common git
|
|
5
|
+
* directory instead so callers always get the canonical place where `.mintree/`
|
|
6
|
+
* lives. Returns `null` when not inside a git repository.
|
|
7
|
+
*/
|
|
8
|
+
export declare function findMainRepoRoot(cwd?: string): string | null;
|
|
9
|
+
export declare function getMintreeDir(repoRoot: string): string;
|
|
10
|
+
export declare function getMetadataPath(repoRoot: string): string;
|
|
11
|
+
export declare function getWorktreesDir(repoRoot: string): string;
|
|
12
|
+
export declare function getSessionStatesDir(repoRoot: string): string;
|
|
13
|
+
export declare function getInitScriptPath(repoRoot: string): string;
|
|
14
|
+
/** Checks whether a path is gitignored according to the repo's rules. */
|
|
15
|
+
export declare function isGitIgnored(relativePath: string, cwd: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True when the path is currently tracked by git in the repo at `cwd`. A
|
|
18
|
+
* gitignore'd path can still be tracked if it was added before being
|
|
19
|
+
* ignored — in that case `git rm --cached` is required to untrack it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isGitTracked(relativePath: string, cwd: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Looks for a file or directory in the repo that's likely to document the
|
|
24
|
+
* project's branch / git conventions. The first hit wins — we just want
|
|
25
|
+
* something to point the user at, not an exhaustive scan. Paths returned
|
|
26
|
+
* are relative to `repoRoot` so they're safe to display.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findBranchConventionDoc(repoRoot: string): string | null;
|
|
29
|
+
export declare function pathExists(p: string): boolean;
|
|
30
|
+
export declare function isExecutable(p: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Appends `entries` to `<repoRoot>/.gitignore`, skipping any entry already
|
|
33
|
+
* matched by the repo's gitignore rules. Creates the file if missing. Returns
|
|
34
|
+
* the entries that were actually appended.
|
|
35
|
+
*/
|
|
36
|
+
export declare function ensureGitignoreEntries(repoRoot: string, entries: string[]): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Best-effort default branch detection. Tries `origin/HEAD` first (the most
|
|
39
|
+
* authoritative source when the repo has a remote), then falls back to `main`
|
|
40
|
+
* and `master` as on-disk heuristics. Returns null only when none of those
|
|
41
|
+
* exist locally or on the remote.
|
|
42
|
+
*/
|
|
43
|
+
export declare function getDefaultBranch(repoRoot: string): string | null;
|
|
44
|
+
export type BranchExistence = "local" | "remote" | null;
|
|
45
|
+
export declare function branchExists(repoRoot: string, branch: string): BranchExistence;
|
|
46
|
+
/**
|
|
47
|
+
* Returns the absolute path where `branch` is checked out as a worktree, or
|
|
48
|
+
* null when the branch is not checked out anywhere. Parses the porcelain
|
|
49
|
+
* format of `git worktree list --porcelain`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function worktreeForBranch(repoRoot: string, branch: string): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Creates a git worktree at `worktreePath` checked out on `branch`. Behavior
|
|
54
|
+
* depending on whether `branch` already exists:
|
|
55
|
+
* - new branch: `git worktree add -b <branch> <path> <base>`
|
|
56
|
+
* - local branch: `git worktree add <path> <branch>`
|
|
57
|
+
* - remote-only branch: `git worktree add --track -b <branch> <path>
|
|
58
|
+
* origin/<branch>` (creates a tracking local)
|
|
59
|
+
*
|
|
60
|
+
* Throws on failure with stderr included so the caller can surface it.
|
|
61
|
+
*/
|
|
62
|
+
export declare function addWorktree(args: {
|
|
63
|
+
repoRoot: string;
|
|
64
|
+
branch: string;
|
|
65
|
+
worktreePath: string;
|
|
66
|
+
base?: string;
|
|
67
|
+
}): void;
|
|
68
|
+
/**
|
|
69
|
+
* Removes a worktree via `git worktree remove`. With `force=true`, also
|
|
70
|
+
* removes the worktree even if it has uncommitted changes. Throws on failure.
|
|
71
|
+
*/
|
|
72
|
+
export declare function removeWorktree(args: {
|
|
73
|
+
repoRoot: string;
|
|
74
|
+
worktreePath: string;
|
|
75
|
+
force?: boolean;
|
|
76
|
+
}): void;
|
|
77
|
+
/**
|
|
78
|
+
* Runs `git worktree prune` to clean up worktree references whose on-disk
|
|
79
|
+
* directory no longer exists.
|
|
80
|
+
*/
|
|
81
|
+
export declare function pruneWorktrees(repoRoot: string): void;
|
|
82
|
+
export type WorktreeEntry = {
|
|
83
|
+
path: string;
|
|
84
|
+
branch: string | null;
|
|
85
|
+
head: string | null;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Parses `git worktree list --porcelain` into structured entries. Includes
|
|
89
|
+
* detached HEADs (branch=null) and the main worktree. Caller is responsible
|
|
90
|
+
* for filtering to mintree-managed worktrees.
|
|
91
|
+
*/
|
|
92
|
+
export declare function listWorktrees(repoRoot: string): WorktreeEntry[];
|
|
93
|
+
/** True when the worktree has any uncommitted changes (porcelain non-empty). */
|
|
94
|
+
export declare function isDirty(worktreePath: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Returns the current branch of the git checkout at `cwd`, or null when in a
|
|
97
|
+
* detached HEAD or outside a git repo.
|
|
98
|
+
*/
|
|
99
|
+
export declare function getCurrentBranch(cwd: string): string | null;
|
|
100
|
+
export type AheadBehind = {
|
|
101
|
+
ahead: number;
|
|
102
|
+
behind: number;
|
|
103
|
+
against: string;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Returns commits ahead/behind `against` from the worktree's HEAD. `against`
|
|
107
|
+
* is resolved in this priority: explicit param > `@{upstream}` > null.
|
|
108
|
+
* Returns null when no comparison ref is available.
|
|
109
|
+
*/
|
|
110
|
+
export declare function getAheadBehind(worktreePath: string, against?: string): AheadBehind | null;
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
/**
|
|
5
|
+
* Returns the absolute path of the **main** git repo root (not a worktree's
|
|
6
|
+
* checkout). When invoked from inside a linked worktree, `git rev-parse
|
|
7
|
+
* --show-toplevel` would return the worktree path; we resolve the common git
|
|
8
|
+
* directory instead so callers always get the canonical place where `.mintree/`
|
|
9
|
+
* lives. Returns `null` when not inside a git repository.
|
|
10
|
+
*/
|
|
11
|
+
export function findMainRepoRoot(cwd = process.cwd()) {
|
|
12
|
+
try {
|
|
13
|
+
const commonDir = execSync("git rev-parse --git-common-dir", {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
})
|
|
17
|
+
.toString()
|
|
18
|
+
.trim();
|
|
19
|
+
const absoluteCommonDir = path.isAbsolute(commonDir)
|
|
20
|
+
? commonDir
|
|
21
|
+
: path.resolve(cwd, commonDir);
|
|
22
|
+
// `--git-common-dir` points at `<root>/.git` in a normal repo. Its parent
|
|
23
|
+
// is the working tree root we want.
|
|
24
|
+
if (path.basename(absoluteCommonDir) === ".git") {
|
|
25
|
+
return path.dirname(absoluteCommonDir);
|
|
26
|
+
}
|
|
27
|
+
// Bare repos or unusual setups: fall back to --show-toplevel.
|
|
28
|
+
const top = execSync("git rev-parse --show-toplevel", {
|
|
29
|
+
cwd,
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
})
|
|
32
|
+
.toString()
|
|
33
|
+
.trim();
|
|
34
|
+
return top || null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function getMintreeDir(repoRoot) {
|
|
41
|
+
return path.join(repoRoot, ".mintree");
|
|
42
|
+
}
|
|
43
|
+
export function getMetadataPath(repoRoot) {
|
|
44
|
+
return path.join(getMintreeDir(repoRoot), "metadata.json");
|
|
45
|
+
}
|
|
46
|
+
export function getWorktreesDir(repoRoot) {
|
|
47
|
+
return path.join(getMintreeDir(repoRoot), "worktrees");
|
|
48
|
+
}
|
|
49
|
+
export function getSessionStatesDir(repoRoot) {
|
|
50
|
+
return path.join(getMintreeDir(repoRoot), "session-states");
|
|
51
|
+
}
|
|
52
|
+
export function getInitScriptPath(repoRoot) {
|
|
53
|
+
return path.join(getMintreeDir(repoRoot), "init.sh");
|
|
54
|
+
}
|
|
55
|
+
/** Checks whether a path is gitignored according to the repo's rules. */
|
|
56
|
+
export function isGitIgnored(relativePath, cwd) {
|
|
57
|
+
try {
|
|
58
|
+
execSync(`git check-ignore -q "${relativePath}"`, { cwd, stdio: "ignore" });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* True when the path is currently tracked by git in the repo at `cwd`. A
|
|
67
|
+
* gitignore'd path can still be tracked if it was added before being
|
|
68
|
+
* ignored — in that case `git rm --cached` is required to untrack it.
|
|
69
|
+
*/
|
|
70
|
+
export function isGitTracked(relativePath, cwd) {
|
|
71
|
+
try {
|
|
72
|
+
execSync(`git ls-files --error-unmatch "${relativePath}"`, {
|
|
73
|
+
cwd,
|
|
74
|
+
stdio: "ignore",
|
|
75
|
+
});
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Looks for a file or directory in the repo that's likely to document the
|
|
84
|
+
* project's branch / git conventions. The first hit wins — we just want
|
|
85
|
+
* something to point the user at, not an exhaustive scan. Paths returned
|
|
86
|
+
* are relative to `repoRoot` so they're safe to display.
|
|
87
|
+
*/
|
|
88
|
+
export function findBranchConventionDoc(repoRoot) {
|
|
89
|
+
const candidates = [
|
|
90
|
+
"docs/conventions/git-workflow.md",
|
|
91
|
+
"docs/git-workflow.md",
|
|
92
|
+
"docs/branching.md",
|
|
93
|
+
"BRANCHING.md",
|
|
94
|
+
"CONTRIBUTING.md",
|
|
95
|
+
".claude/skills",
|
|
96
|
+
];
|
|
97
|
+
for (const c of candidates) {
|
|
98
|
+
if (fs.existsSync(path.join(repoRoot, c)))
|
|
99
|
+
return c;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
export function pathExists(p) {
|
|
104
|
+
return fs.existsSync(p);
|
|
105
|
+
}
|
|
106
|
+
export function isExecutable(p) {
|
|
107
|
+
try {
|
|
108
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Appends `entries` to `<repoRoot>/.gitignore`, skipping any entry already
|
|
117
|
+
* matched by the repo's gitignore rules. Creates the file if missing. Returns
|
|
118
|
+
* the entries that were actually appended.
|
|
119
|
+
*/
|
|
120
|
+
export function ensureGitignoreEntries(repoRoot, entries) {
|
|
121
|
+
const gitignorePath = path.join(repoRoot, ".gitignore");
|
|
122
|
+
const toAdd = entries.filter(entry => !isGitIgnored(entry, repoRoot));
|
|
123
|
+
if (toAdd.length === 0)
|
|
124
|
+
return [];
|
|
125
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : "";
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (existing.length > 0 && !existing.endsWith("\n"))
|
|
128
|
+
parts.push("\n");
|
|
129
|
+
if (existing.length > 0)
|
|
130
|
+
parts.push("\n");
|
|
131
|
+
parts.push("# mintree\n");
|
|
132
|
+
parts.push(toAdd.join("\n") + "\n");
|
|
133
|
+
fs.appendFileSync(gitignorePath, parts.join(""));
|
|
134
|
+
return toAdd;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Best-effort default branch detection. Tries `origin/HEAD` first (the most
|
|
138
|
+
* authoritative source when the repo has a remote), then falls back to `main`
|
|
139
|
+
* and `master` as on-disk heuristics. Returns null only when none of those
|
|
140
|
+
* exist locally or on the remote.
|
|
141
|
+
*/
|
|
142
|
+
export function getDefaultBranch(repoRoot) {
|
|
143
|
+
const head = trySh(`git symbolic-ref refs/remotes/origin/HEAD`, repoRoot);
|
|
144
|
+
if (head) {
|
|
145
|
+
// e.g. "refs/remotes/origin/main" -> "main"
|
|
146
|
+
const m = head.match(/refs\/remotes\/origin\/(.+)$/);
|
|
147
|
+
if (m && m[1])
|
|
148
|
+
return m[1];
|
|
149
|
+
}
|
|
150
|
+
for (const candidate of ["main", "master"]) {
|
|
151
|
+
if (branchExists(repoRoot, candidate) !== null)
|
|
152
|
+
return candidate;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
export function branchExists(repoRoot, branch) {
|
|
157
|
+
if (trySh(`git rev-parse --verify --quiet "refs/heads/${branch}"`, repoRoot))
|
|
158
|
+
return "local";
|
|
159
|
+
if (trySh(`git rev-parse --verify --quiet "refs/remotes/origin/${branch}"`, repoRoot))
|
|
160
|
+
return "remote";
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Returns the absolute path where `branch` is checked out as a worktree, or
|
|
165
|
+
* null when the branch is not checked out anywhere. Parses the porcelain
|
|
166
|
+
* format of `git worktree list --porcelain`.
|
|
167
|
+
*/
|
|
168
|
+
export function worktreeForBranch(repoRoot, branch) {
|
|
169
|
+
const output = trySh(`git worktree list --porcelain`, repoRoot);
|
|
170
|
+
if (!output)
|
|
171
|
+
return null;
|
|
172
|
+
const ref = `refs/heads/${branch}`;
|
|
173
|
+
const blocks = output.split(/\n\n+/);
|
|
174
|
+
for (const block of blocks) {
|
|
175
|
+
const lines = block.split("\n");
|
|
176
|
+
let wtPath = null;
|
|
177
|
+
let wtBranch = null;
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
if (line.startsWith("worktree "))
|
|
180
|
+
wtPath = line.slice("worktree ".length);
|
|
181
|
+
if (line.startsWith("branch "))
|
|
182
|
+
wtBranch = line.slice("branch ".length);
|
|
183
|
+
}
|
|
184
|
+
if (wtPath && wtBranch === ref)
|
|
185
|
+
return wtPath;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Creates a git worktree at `worktreePath` checked out on `branch`. Behavior
|
|
191
|
+
* depending on whether `branch` already exists:
|
|
192
|
+
* - new branch: `git worktree add -b <branch> <path> <base>`
|
|
193
|
+
* - local branch: `git worktree add <path> <branch>`
|
|
194
|
+
* - remote-only branch: `git worktree add --track -b <branch> <path>
|
|
195
|
+
* origin/<branch>` (creates a tracking local)
|
|
196
|
+
*
|
|
197
|
+
* Throws on failure with stderr included so the caller can surface it.
|
|
198
|
+
*/
|
|
199
|
+
export function addWorktree(args) {
|
|
200
|
+
const { repoRoot, branch, worktreePath, base } = args;
|
|
201
|
+
const existence = branchExists(repoRoot, branch);
|
|
202
|
+
const safePath = shellQuote(worktreePath);
|
|
203
|
+
const safeBranch = shellQuote(branch);
|
|
204
|
+
let cmd;
|
|
205
|
+
if (existence === "local") {
|
|
206
|
+
cmd = `git worktree add ${safePath} ${safeBranch}`;
|
|
207
|
+
}
|
|
208
|
+
else if (existence === "remote") {
|
|
209
|
+
cmd = `git worktree add --track -b ${safeBranch} ${safePath} ${shellQuote(`origin/${branch}`)}`;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
if (!base)
|
|
213
|
+
throw new Error(`Cannot create new branch ${branch}: no base branch resolved.`);
|
|
214
|
+
cmd = `git worktree add -b ${safeBranch} ${safePath} ${shellQuote(base)}`;
|
|
215
|
+
}
|
|
216
|
+
execSync(cmd, { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Removes a worktree via `git worktree remove`. With `force=true`, also
|
|
220
|
+
* removes the worktree even if it has uncommitted changes. Throws on failure.
|
|
221
|
+
*/
|
|
222
|
+
export function removeWorktree(args) {
|
|
223
|
+
const { repoRoot, worktreePath, force } = args;
|
|
224
|
+
const cmd = `git worktree remove ${force ? "--force " : ""}${shellQuote(worktreePath)}`;
|
|
225
|
+
execSync(cmd, { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Runs `git worktree prune` to clean up worktree references whose on-disk
|
|
229
|
+
* directory no longer exists.
|
|
230
|
+
*/
|
|
231
|
+
export function pruneWorktrees(repoRoot) {
|
|
232
|
+
execSync("git worktree prune", { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Runs a shell command in `cwd` and returns trimmed stdout, or null if the
|
|
236
|
+
* command exits non-zero. Used for git probes whose absence is meaningful.
|
|
237
|
+
*/
|
|
238
|
+
function trySh(cmd, cwd) {
|
|
239
|
+
try {
|
|
240
|
+
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "ignore"] })
|
|
241
|
+
.toString()
|
|
242
|
+
.trim();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Single-quote a value for safe inclusion in a shell command line. */
|
|
249
|
+
function shellQuote(value) {
|
|
250
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Parses `git worktree list --porcelain` into structured entries. Includes
|
|
254
|
+
* detached HEADs (branch=null) and the main worktree. Caller is responsible
|
|
255
|
+
* for filtering to mintree-managed worktrees.
|
|
256
|
+
*/
|
|
257
|
+
export function listWorktrees(repoRoot) {
|
|
258
|
+
const output = trySh("git worktree list --porcelain", repoRoot);
|
|
259
|
+
if (!output)
|
|
260
|
+
return [];
|
|
261
|
+
const entries = [];
|
|
262
|
+
const blocks = output.split(/\n\n+/);
|
|
263
|
+
for (const block of blocks) {
|
|
264
|
+
let entryPath = null;
|
|
265
|
+
let head = null;
|
|
266
|
+
let branch = null;
|
|
267
|
+
for (const line of block.split("\n")) {
|
|
268
|
+
if (line.startsWith("worktree "))
|
|
269
|
+
entryPath = line.slice("worktree ".length);
|
|
270
|
+
else if (line.startsWith("HEAD "))
|
|
271
|
+
head = line.slice("HEAD ".length);
|
|
272
|
+
else if (line.startsWith("branch ")) {
|
|
273
|
+
const ref = line.slice("branch ".length);
|
|
274
|
+
branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (entryPath)
|
|
278
|
+
entries.push({ path: entryPath, branch, head });
|
|
279
|
+
}
|
|
280
|
+
return entries;
|
|
281
|
+
}
|
|
282
|
+
/** True when the worktree has any uncommitted changes (porcelain non-empty). */
|
|
283
|
+
export function isDirty(worktreePath) {
|
|
284
|
+
const out = trySh("git status --porcelain", worktreePath);
|
|
285
|
+
return out !== null && out.length > 0;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Returns the current branch of the git checkout at `cwd`, or null when in a
|
|
289
|
+
* detached HEAD or outside a git repo.
|
|
290
|
+
*/
|
|
291
|
+
export function getCurrentBranch(cwd) {
|
|
292
|
+
const out = trySh("git symbolic-ref --short -q HEAD", cwd);
|
|
293
|
+
return out && out.length > 0 ? out : null;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Returns commits ahead/behind `against` from the worktree's HEAD. `against`
|
|
297
|
+
* is resolved in this priority: explicit param > `@{upstream}` > null.
|
|
298
|
+
* Returns null when no comparison ref is available.
|
|
299
|
+
*/
|
|
300
|
+
export function getAheadBehind(worktreePath, against) {
|
|
301
|
+
let ref = against;
|
|
302
|
+
if (!ref) {
|
|
303
|
+
const upstream = trySh("git rev-parse --abbrev-ref --symbolic-full-name @{upstream}", worktreePath);
|
|
304
|
+
if (upstream)
|
|
305
|
+
ref = upstream;
|
|
306
|
+
}
|
|
307
|
+
if (!ref)
|
|
308
|
+
return null;
|
|
309
|
+
const counts = trySh(`git rev-list --left-right --count HEAD...${shellQuote(ref)}`, worktreePath);
|
|
310
|
+
if (!counts)
|
|
311
|
+
return null;
|
|
312
|
+
const parts = counts.split(/\s+/);
|
|
313
|
+
if (parts.length < 2)
|
|
314
|
+
return null;
|
|
315
|
+
const ahead = Number(parts[0]);
|
|
316
|
+
const behind = Number(parts[1]);
|
|
317
|
+
if (Number.isNaN(ahead) || Number.isNaN(behind))
|
|
318
|
+
return null;
|
|
319
|
+
return { ahead, behind, against: ref };
|
|
320
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function ghCliAvailable(): Promise<boolean>;
|
|
2
|
+
export declare function getGhUserLogin(): Promise<string | null>;
|
|
3
|
+
/**
|
|
4
|
+
* Returns "owner/name" for the GitHub repo of the current working directory,
|
|
5
|
+
* or null if not a GitHub repo / `gh` can't reach the API.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getRepoFullName(): Promise<string | null>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { tryExec } from "./exec.js";
|
|
2
|
+
export async function ghCliAvailable() {
|
|
3
|
+
const out = await tryExec("which gh");
|
|
4
|
+
return !!out;
|
|
5
|
+
}
|
|
6
|
+
export async function getGhUserLogin() {
|
|
7
|
+
return tryExec("gh api user --jq .login 2>/dev/null");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Returns "owner/name" for the GitHub repo of the current working directory,
|
|
11
|
+
* or null if not a GitHub repo / `gh` can't reach the API.
|
|
12
|
+
*/
|
|
13
|
+
export async function getRepoFullName() {
|
|
14
|
+
return tryExec("gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null");
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emits the shell-wrapper markers. When `MINTREE_MARKER_FILE` is set in env
|
|
3
|
+
* (the dashboard wrapper does this so it can run the TUI without capturing
|
|
4
|
+
* stdout), the markers are appended there. Otherwise they go to stdout —
|
|
5
|
+
* the `worktree create` wrapper greps stdout for them after capturing it.
|
|
6
|
+
*
|
|
7
|
+
* Each marker is written on its own line, terminated with a newline.
|
|
8
|
+
*/
|
|
9
|
+
export declare function emitMarkers(markers: string[]): void;
|
|
10
|
+
export type CreateMarkers = {
|
|
11
|
+
worktreePath: string;
|
|
12
|
+
work: boolean;
|
|
13
|
+
promptFile?: string;
|
|
14
|
+
permissionMode?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Builds the marker block emitted after a successful `worktree create`.
|
|
18
|
+
* Same layout the shell wrapper expects: MINTREE_CD always present, the
|
|
19
|
+
* three work-related markers only when --work was on.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildCreateMarkers(input: CreateMarkers): string[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
/**
|
|
3
|
+
* Emits the shell-wrapper markers. When `MINTREE_MARKER_FILE` is set in env
|
|
4
|
+
* (the dashboard wrapper does this so it can run the TUI without capturing
|
|
5
|
+
* stdout), the markers are appended there. Otherwise they go to stdout —
|
|
6
|
+
* the `worktree create` wrapper greps stdout for them after capturing it.
|
|
7
|
+
*
|
|
8
|
+
* Each marker is written on its own line, terminated with a newline.
|
|
9
|
+
*/
|
|
10
|
+
export function emitMarkers(markers) {
|
|
11
|
+
if (markers.length === 0)
|
|
12
|
+
return;
|
|
13
|
+
const text = markers.join("\n") + "\n";
|
|
14
|
+
const file = process.env["MINTREE_MARKER_FILE"];
|
|
15
|
+
if (file) {
|
|
16
|
+
try {
|
|
17
|
+
fs.appendFileSync(file, text);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// If the file is unwritable for any reason, fall through to stdout
|
|
22
|
+
// rather than silently swallow the markers.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
process.stdout.write(text);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Builds the marker block emitted after a successful `worktree create`.
|
|
29
|
+
* Same layout the shell wrapper expects: MINTREE_CD always present, the
|
|
30
|
+
* three work-related markers only when --work was on.
|
|
31
|
+
*/
|
|
32
|
+
export function buildCreateMarkers(input) {
|
|
33
|
+
const lines = [`MINTREE_CD:${input.worktreePath}`];
|
|
34
|
+
if (input.work)
|
|
35
|
+
lines.push("MINTREE_WORK:1");
|
|
36
|
+
if (input.work && input.promptFile) {
|
|
37
|
+
lines.push(`MINTREE_WORK_PROMPT_FILE:${input.promptFile}`);
|
|
38
|
+
}
|
|
39
|
+
if (input.work && input.permissionMode) {
|
|
40
|
+
lines.push(`MINTREE_PERMISSION_MODE:${input.permissionMode}`);
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type IssueMeta = {
|
|
2
|
+
base_branch?: string;
|
|
3
|
+
session_id?: string;
|
|
4
|
+
};
|
|
5
|
+
export type Metadata = {
|
|
6
|
+
version: 1;
|
|
7
|
+
issues: Record<string, IssueMeta>;
|
|
8
|
+
};
|
|
9
|
+
export declare function readMetadata(repoRoot: string): Metadata;
|
|
10
|
+
export declare function writeMetadata(repoRoot: string, data: Metadata): void;
|
|
11
|
+
/**
|
|
12
|
+
* Merges `partial` into the metadata entry for `issueId`, creating the entry
|
|
13
|
+
* if missing. Persists the result. Existing fields not present in `partial`
|
|
14
|
+
* are preserved.
|
|
15
|
+
*/
|
|
16
|
+
export declare function upsertIssue(repoRoot: string, issueId: string, partial: IssueMeta): Metadata;
|
|
17
|
+
export declare function getSessionId(repoRoot: string, issueId: string): string | undefined;
|
|
18
|
+
export declare function setSessionId(repoRoot: string, issueId: string, sessionId: string): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { getMetadataPath } from "./git.js";
|
|
3
|
+
const EMPTY = { version: 1, issues: {} };
|
|
4
|
+
export function readMetadata(repoRoot) {
|
|
5
|
+
const filePath = getMetadataPath(repoRoot);
|
|
6
|
+
if (!fs.existsSync(filePath))
|
|
7
|
+
return { ...EMPTY, issues: {} };
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
10
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
11
|
+
return { ...EMPTY, issues: {} };
|
|
12
|
+
return {
|
|
13
|
+
version: 1,
|
|
14
|
+
issues: typeof parsed.issues === "object" && parsed.issues !== null
|
|
15
|
+
? parsed.issues
|
|
16
|
+
: {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { ...EMPTY, issues: {} };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function writeMetadata(repoRoot, data) {
|
|
24
|
+
const filePath = getMetadataPath(repoRoot);
|
|
25
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Merges `partial` into the metadata entry for `issueId`, creating the entry
|
|
29
|
+
* if missing. Persists the result. Existing fields not present in `partial`
|
|
30
|
+
* are preserved.
|
|
31
|
+
*/
|
|
32
|
+
export function upsertIssue(repoRoot, issueId, partial) {
|
|
33
|
+
const data = readMetadata(repoRoot);
|
|
34
|
+
const previous = data.issues[issueId] ?? {};
|
|
35
|
+
data.issues[issueId] = { ...previous, ...partial };
|
|
36
|
+
writeMetadata(repoRoot, data);
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
export function getSessionId(repoRoot, issueId) {
|
|
40
|
+
return readMetadata(repoRoot).issues[issueId]?.session_id;
|
|
41
|
+
}
|
|
42
|
+
export function setSessionId(repoRoot, issueId, sessionId) {
|
|
43
|
+
upsertIssue(repoRoot, issueId, { session_id: sessionId });
|
|
44
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type SessionState = "waiting" | "idle" | "active" | "exited";
|
|
2
|
+
/**
|
|
3
|
+
* Synchronously slurps stdin to a string. Used by hook handlers to read the
|
|
4
|
+
* JSON payload Claude pipes in. Returns "" on any failure so the caller can
|
|
5
|
+
* exit silently — a hook crash would interrupt Claude.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readStdin(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Extracts the main repo root and worktree directory name from an absolute
|
|
10
|
+
* cwd that lives under `<repo>/.mintree/worktrees/<dir>/...`. Returns null
|
|
11
|
+
* when the cwd is outside that pattern (Claude was launched somewhere else
|
|
12
|
+
* or the worktree was already removed).
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractRepoAndDir(cwd: string): {
|
|
15
|
+
repoRoot: string;
|
|
16
|
+
worktreeDir: string;
|
|
17
|
+
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* Pulls the issue number out of a `<issue>-<desc>` worktree directory name.
|
|
20
|
+
* Returns null when the directory name doesn't follow the convention (e.g.
|
|
21
|
+
* a manually-created worktree dropped under .mintree/worktrees/).
|
|
22
|
+
*/
|
|
23
|
+
export declare function issueIdFromWorktreeDir(worktreeDir: string): string | null;
|
|
24
|
+
export type StatePayload = {
|
|
25
|
+
state: SessionState;
|
|
26
|
+
session_id: string;
|
|
27
|
+
issue_id: string;
|
|
28
|
+
worktree_dir: string;
|
|
29
|
+
message: string | null;
|
|
30
|
+
at: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Writes the state file for an issue under `<repo>/.mintree/session-states/`.
|
|
34
|
+
* Creates the directory if missing. Atomic-ish: same write pattern as
|
|
35
|
+
* metadata.json — fine for state probing from the dashboard, fast to refresh.
|
|
36
|
+
*/
|
|
37
|
+
export declare function writeStateFile(repoRoot: string, issueId: string, payload: StatePayload): string;
|
|
38
|
+
/**
|
|
39
|
+
* The only entry point the hook sub-commands call. Reads the JSON payload
|
|
40
|
+
* Claude piped in, locates the worktree+issue from the payload's `cwd`, and
|
|
41
|
+
* writes the state file. Exits 0 unconditionally — the worst case is a
|
|
42
|
+
* silent no-op, never an error that would interrupt Claude.
|
|
43
|
+
*/
|
|
44
|
+
export declare function signalState(state: SessionState): void;
|
|
45
|
+
/**
|
|
46
|
+
* The hook tree mintree wants to see in `~/.claude/settings.json`. Each
|
|
47
|
+
* inner command runs async with a 10s timeout — slow hooks would otherwise
|
|
48
|
+
* block Claude's UI thread. The Notification entry is gated on the
|
|
49
|
+
* `permission_prompt` matcher so the dashboard's "waiting" state only
|
|
50
|
+
* lights up when Claude is actually waiting for a permission decision,
|
|
51
|
+
* not for every notification.
|
|
52
|
+
*/
|
|
53
|
+
export declare function getHooksJson(): Record<string, unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* Installs (or replaces) the four mintree hooks in `~/.claude/settings.json`.
|
|
56
|
+
* Existing non-mintree hooks for the same events are preserved; previous
|
|
57
|
+
* mintree entries are filtered out and re-added so re-running this is safe.
|
|
58
|
+
* Returns the path of the file we wrote.
|
|
59
|
+
*/
|
|
60
|
+
export declare function installHooks(): {
|
|
61
|
+
settingsPath: string;
|
|
62
|
+
created: boolean;
|
|
63
|
+
};
|