@solaqua/gji 0.15.0 → 0.16.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 +63 -120
- package/dist/cli/commands/back.d.ts +1 -1
- package/dist/cli/commands/back.js +1 -0
- package/dist/cli/commands/clean.d.ts +1 -1
- package/dist/cli/commands/clean.js +11 -2
- package/dist/cli/commands/done.js +3 -2
- package/dist/cli/commands/go.js +18 -5
- package/dist/cli/commands/init.js +0 -14
- package/dist/cli/commands/new.d.ts +2 -1
- package/dist/cli/commands/new.js +71 -63
- package/dist/cli/commands/open.js +1 -1
- package/dist/cli/commands/pr-open.js +2 -2
- package/dist/cli/commands/pr.js +17 -6
- package/dist/cli/commands/run-hook.js +4 -0
- package/dist/cli/commands/undo.js +10 -74
- package/dist/cli/dependencies.d.ts +5 -1
- package/dist/cli/dependencies.js +7 -2
- package/dist/cli/program.js +1 -56
- package/dist/cli/runtime/headless.d.ts +3 -3
- package/dist/cli/runtime/headless.js +6 -4
- package/dist/gji-bundle.mjs +500 -842
- package/dist/infrastructure/filesystem/locked-file.d.ts +4 -0
- package/dist/infrastructure/filesystem/locked-file.js +53 -0
- package/dist/infrastructure/git/runner.d.ts +7 -0
- package/dist/infrastructure/git/runner.js +35 -1
- package/dist/infrastructure/persistence/history.js +12 -10
- package/dist/infrastructure/persistence/slots.js +24 -21
- package/dist/infrastructure/process/hooks.js +2 -0
- package/dist/infrastructure/repository/registry.js +11 -41
- package/dist/infrastructure/worktree/lifecycle.d.ts +25 -0
- package/dist/infrastructure/worktree/lifecycle.js +120 -19
- package/dist/ports/git.d.ts +1 -0
- package/dist/ports/hooks.d.ts +7 -0
- package/dist/presentation/shell/completion.js +7 -30
- package/dist/presentation/terminal/context-card.js +1 -1
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-doctor.1 +1 -1
- package/man/man1/gji-done.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +3 -3
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-task.1 +1 -1
- package/man/man1/gji-undo.1 +1 -1
- package/man/man1/gji.1 +2 -12
- package/package.json +3 -2
- package/dist/cli/commands/remove.d.ts +0 -21
- package/dist/cli/commands/remove.js +0 -189
- package/dist/cli/commands/warp.d.ts +0 -28
- package/dist/cli/commands/warp.js +0 -123
- package/man/man1/gji-remove.1 +0 -19
- package/man/man1/gji-warp.1 +0 -16
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Serializes read-modify-write cycles on `path` across gji processes. */
|
|
2
|
+
export declare function withFileLock<T>(path: string, operation: () => Promise<T>): Promise<T>;
|
|
3
|
+
/** Replaces `path` in one step so concurrent readers never see a partial file. */
|
|
4
|
+
export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { isAlreadyExistsError } from "./fs-utils.js";
|
|
6
|
+
const LOCK_STALE_AFTER_MS = 10_000;
|
|
7
|
+
const LOCK_RETRY_DELAY_MS = 5;
|
|
8
|
+
const LOCK_WAIT_TIMEOUT_MS = LOCK_STALE_AFTER_MS + LOCK_RETRY_DELAY_MS;
|
|
9
|
+
/** Serializes read-modify-write cycles on `path` across gji processes. */
|
|
10
|
+
export async function withFileLock(path, operation) {
|
|
11
|
+
await mkdir(dirname(path), { recursive: true });
|
|
12
|
+
const release = await acquireFileLock(path);
|
|
13
|
+
try {
|
|
14
|
+
return await operation();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
await release();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Replaces `path` in one step so concurrent readers never see a partial file. */
|
|
21
|
+
export async function writeJsonAtomic(path, value) {
|
|
22
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
25
|
+
await rename(temporaryPath, path);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function acquireFileLock(path) {
|
|
33
|
+
const lockPath = `${path}.lock`;
|
|
34
|
+
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
try {
|
|
37
|
+
const handle = await open(lockPath, "wx");
|
|
38
|
+
return async () => {
|
|
39
|
+
await handle.close();
|
|
40
|
+
await unlink(lockPath).catch(() => undefined);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!isAlreadyExistsError(error))
|
|
45
|
+
throw error;
|
|
46
|
+
const lockStat = await stat(lockPath).catch(() => null);
|
|
47
|
+
if (lockStat && Date.now() - lockStat.mtimeMs > LOCK_STALE_AFTER_MS)
|
|
48
|
+
await unlink(lockPath).catch(() => undefined);
|
|
49
|
+
await delay(LOCK_RETRY_DELAY_MS);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`timed out waiting for the lock on ${path}`);
|
|
53
|
+
}
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
+
export declare const GIT_ENV: {
|
|
2
|
+
LC_ALL: string;
|
|
3
|
+
};
|
|
1
4
|
export declare function runGit(cwd: string, args: string[]): Promise<string>;
|
|
2
5
|
export declare function runGitRaw(cwd: string, args: string[]): Promise<string>;
|
|
6
|
+
/** Runs a Git command that can lose a race with another Git process. */
|
|
7
|
+
export declare function runGitRetryingContention(cwd: string, args: string[]): Promise<string>;
|
|
8
|
+
/** Retries `operation`, with jittered backoff, while Git reports contention with another process. */
|
|
9
|
+
export declare function retryGitContention<T>(operation: () => Promise<T>, timeoutMs?: number): Promise<T>;
|
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
|
+
const CONTENTION_RETRY_TIMEOUT_MS = 10_000;
|
|
5
|
+
const CONTENTION_RETRY_MAX_DELAY_MS = 1_000;
|
|
6
|
+
// Force English output so error message string matching is locale-independent.
|
|
7
|
+
export const GIT_ENV = { ...process.env, LC_ALL: "C" };
|
|
4
8
|
export async function runGit(cwd, args) {
|
|
5
9
|
return (await runGitRaw(cwd, args)).trim();
|
|
6
10
|
}
|
|
7
11
|
export async function runGitRaw(cwd, args) {
|
|
12
|
+
return execGit(cwd, args);
|
|
13
|
+
}
|
|
14
|
+
/** Runs a Git command that can lose a race with another Git process. */
|
|
15
|
+
export async function runGitRetryingContention(cwd, args) {
|
|
16
|
+
return (await retryGitContention(() => execGit(cwd, args, GIT_ENV))).trim();
|
|
17
|
+
}
|
|
18
|
+
/** Retries `operation`, with jittered backoff, while Git reports contention with another process. */
|
|
19
|
+
export async function retryGitContention(operation, timeoutMs = CONTENTION_RETRY_TIMEOUT_MS) {
|
|
20
|
+
const deadline = Date.now() + timeoutMs;
|
|
21
|
+
for (let delayMs = 25;; delayMs *= 2) {
|
|
22
|
+
try {
|
|
23
|
+
return await operation();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!isTransientGitError(error) || Date.now() >= deadline)
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const jitteredDelayMs = Math.min(delayMs, CONTENTION_RETRY_MAX_DELAY_MS) *
|
|
30
|
+
(0.5 + Math.random() / 2);
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, jitteredDelayMs));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function execGit(cwd, args, env) {
|
|
8
35
|
try {
|
|
9
|
-
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
36
|
+
const { stdout } = await execFileAsync("git", args, { cwd, env });
|
|
10
37
|
return stdout;
|
|
11
38
|
}
|
|
12
39
|
catch (error) {
|
|
@@ -14,3 +41,10 @@ export async function runGitRaw(cwd, args) {
|
|
|
14
41
|
throw new Error(`Git command failed in '${cwd}': ${message}`);
|
|
15
42
|
}
|
|
16
43
|
}
|
|
44
|
+
// Only errors that another process can clear: a held lock, or a linked worktree
|
|
45
|
+
// entry that another process is still creating or removing. Git uses the
|
|
46
|
+
// same "cannot lock" wording for permanent failures such as a conflicting branch name.
|
|
47
|
+
function isTransientGitError(error) {
|
|
48
|
+
return (error instanceof Error &&
|
|
49
|
+
/(could not lock config file .*|Unable to create '.*\.lock'): File exists|cannot lock ref .*: is at .* but expected |failed to read .*\/commondir|Invalid path '.*\/worktrees\//.test(error.message));
|
|
50
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
|
|
4
5
|
import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
|
|
5
6
|
export const HISTORY_FILE_NAME = "history.json";
|
|
6
7
|
const MAX_HISTORY_ENTRIES = 50;
|
|
@@ -26,14 +27,15 @@ export async function loadHistory(home = homedir()) {
|
|
|
26
27
|
}
|
|
27
28
|
export async function appendHistory(path, branch, home = homedir()) {
|
|
28
29
|
const historyPath = HISTORY_FILE_PATH(home);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
await withFileLock(historyPath, async () => {
|
|
31
|
+
const existing = await loadHistory(home);
|
|
32
|
+
const entry = { branch, path, timestamp: Date.now() };
|
|
33
|
+
const next = [
|
|
34
|
+
entry,
|
|
35
|
+
...existing.filter((existingEntry) => existingEntry.path !== path),
|
|
36
|
+
].slice(0, MAX_HISTORY_ENTRIES);
|
|
37
|
+
await writeJsonAtomic(historyPath, next);
|
|
38
|
+
});
|
|
37
39
|
}
|
|
38
40
|
export async function recordWorktreeUsage(path, branch, home = homedir()) {
|
|
39
41
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
|
|
4
5
|
import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
|
|
5
6
|
export function SLOTS_FILE_PATH(home = homedir()) {
|
|
6
7
|
const configDir = process.env.GJI_CONFIG_DIR;
|
|
@@ -20,33 +21,35 @@ export async function loadSlots(home = homedir()) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
export async function assignWorktreeSlot(repoRoot, worktreePath, home = homedir()) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
slots[repoRoot]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
return withFileLock(SLOTS_FILE_PATH(home), async () => {
|
|
25
|
+
const slots = await loadSlots(home);
|
|
26
|
+
const existing = slots[worktreePath];
|
|
27
|
+
if (existing !== undefined)
|
|
28
|
+
return existing;
|
|
29
|
+
if (worktreePath !== repoRoot && slots[repoRoot] === undefined)
|
|
30
|
+
slots[repoRoot] = 0;
|
|
31
|
+
const used = new Set(Object.values(slots));
|
|
32
|
+
const slot = worktreePath === repoRoot ? 0 : nextAvailableSlot(used);
|
|
33
|
+
slots[worktreePath] = slot;
|
|
34
|
+
await saveSlots(slots, home);
|
|
35
|
+
return slot;
|
|
36
|
+
});
|
|
34
37
|
}
|
|
35
38
|
export async function getWorktreeSlot(worktreePath, home = homedir()) {
|
|
36
39
|
const slot = (await loadSlots(home))[worktreePath];
|
|
37
40
|
return slot === undefined ? null : slot;
|
|
38
41
|
}
|
|
39
42
|
export async function releaseWorktreeSlot(worktreePath, home = homedir()) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
await withFileLock(SLOTS_FILE_PATH(home), async () => {
|
|
44
|
+
const slots = await loadSlots(home);
|
|
45
|
+
if (!(worktreePath in slots))
|
|
46
|
+
return;
|
|
47
|
+
delete slots[worktreePath];
|
|
48
|
+
await saveSlots(slots, home);
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
async function saveSlots(slots, home) {
|
|
47
|
-
|
|
48
|
-
await mkdir(dirname(path), { recursive: true });
|
|
49
|
-
await writeFile(path, `${JSON.stringify(slots, null, 2)}\n`, "utf8");
|
|
52
|
+
await writeJsonAtomic(SLOTS_FILE_PATH(home), slots);
|
|
50
53
|
}
|
|
51
54
|
function nextAvailableSlot(used) {
|
|
52
55
|
let slot = 1;
|
|
@@ -96,8 +96,10 @@ export function extractHooks(config) {
|
|
|
96
96
|
}
|
|
97
97
|
const hooks = raw;
|
|
98
98
|
return {
|
|
99
|
+
"after-branch-create": parseHookCommand(hooks["after-branch-create"] ?? hooks.afterBranchCreate),
|
|
99
100
|
"after-create": parseHookCommand(hooks["after-create"] ?? hooks.afterCreate),
|
|
100
101
|
"after-enter": parseHookCommand(hooks["after-enter"] ?? hooks.afterEnter),
|
|
102
|
+
"after-switch": parseHookCommand(hooks["after-switch"] ?? hooks.afterSwitch),
|
|
101
103
|
"before-remove": parseHookCommand(hooks["before-remove"] ?? hooks.beforeRemove),
|
|
102
104
|
};
|
|
103
105
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { constants } from "node:fs";
|
|
3
|
-
import { access,
|
|
2
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
|
-
import { basename,
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
|
|
6
6
|
import { GLOBAL_CONFIG_DIRECTORY } from "../persistence/config.js";
|
|
7
7
|
const REGISTRY_FILE_NAME = "repos.json";
|
|
8
8
|
const MAX_REGISTRY_ENTRIES = 100;
|
|
9
|
-
const registryLocks = new Map();
|
|
10
9
|
export function REGISTRY_FILE_PATH(home = homedir()) {
|
|
11
10
|
const configDir = process.env.GJI_CONFIG_DIR;
|
|
12
11
|
if (configDir) {
|
|
@@ -37,10 +36,12 @@ async function canonicalizeRepoPath(repoPath) {
|
|
|
37
36
|
}
|
|
38
37
|
export async function registerRepo(repoPath, home = homedir()) {
|
|
39
38
|
const registryPath = REGISTRY_FILE_PATH(home);
|
|
40
|
-
|
|
39
|
+
const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
|
|
40
|
+
// Skip the lock and the write if this repo is already the most-recently-used entry (common case).
|
|
41
|
+
if ((await loadRegistry(home))[0]?.path === canonicalRepoPath)
|
|
42
|
+
return;
|
|
43
|
+
await withFileLock(registryPath, async () => {
|
|
41
44
|
const existing = await normalizeRegistryForWrite(await loadRegistry(home));
|
|
42
|
-
const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
|
|
43
|
-
// Skip write if this repo is already the most-recently-used entry (common case).
|
|
44
45
|
if (existing.length > 0 && existing[0].path === canonicalRepoPath)
|
|
45
46
|
return;
|
|
46
47
|
const entry = {
|
|
@@ -50,14 +51,14 @@ export async function registerRepo(repoPath, home = homedir()) {
|
|
|
50
51
|
};
|
|
51
52
|
const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
|
|
52
53
|
const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
|
|
53
|
-
await
|
|
54
|
+
await writeJsonAtomic(registryPath, next);
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
57
|
export async function removeMissingRegistryEntries(paths, home = homedir()) {
|
|
57
58
|
if (paths.size === 0)
|
|
58
59
|
return { removedPaths: [], skippedPaths: [] };
|
|
59
60
|
const registryPath = REGISTRY_FILE_PATH(home);
|
|
60
|
-
return
|
|
61
|
+
return withFileLock(registryPath, async () => {
|
|
61
62
|
const entries = await loadRegistry(home);
|
|
62
63
|
const candidates = entries.filter((entry) => paths.has(entry.path));
|
|
63
64
|
const status = await Promise.all(candidates.map(async (entry) => ({
|
|
@@ -71,30 +72,11 @@ export async function removeMissingRegistryEntries(paths, home = homedir()) {
|
|
|
71
72
|
.filter(({ missing }) => !missing)
|
|
72
73
|
.map(({ entry }) => entry.path);
|
|
73
74
|
if (removedPaths.length > 0) {
|
|
74
|
-
await
|
|
75
|
+
await writeJsonAtomic(registryPath, entries.filter((entry) => !removedPaths.includes(entry.path)));
|
|
75
76
|
}
|
|
76
77
|
return { removedPaths, skippedPaths };
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
|
-
async function withRegistryLock(registryPath, operation) {
|
|
80
|
-
const previous = registryLocks.get(registryPath);
|
|
81
|
-
let release;
|
|
82
|
-
const current = new Promise((resolve) => {
|
|
83
|
-
release = resolve;
|
|
84
|
-
});
|
|
85
|
-
registryLocks.set(registryPath, current);
|
|
86
|
-
if (previous)
|
|
87
|
-
await previous;
|
|
88
|
-
try {
|
|
89
|
-
return await operation();
|
|
90
|
-
}
|
|
91
|
-
finally {
|
|
92
|
-
release();
|
|
93
|
-
if (registryLocks.get(registryPath) === current) {
|
|
94
|
-
registryLocks.delete(registryPath);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
80
|
async function isMissingPath(path) {
|
|
99
81
|
try {
|
|
100
82
|
await access(path, constants.F_OK);
|
|
@@ -110,18 +92,6 @@ function isMissingPathError(error) {
|
|
|
110
92
|
const code = error.code;
|
|
111
93
|
return code === "ENOENT" || code === "ENOTDIR";
|
|
112
94
|
}
|
|
113
|
-
async function writeRegistry(entries, registryPath) {
|
|
114
|
-
await mkdir(dirname(registryPath), { recursive: true });
|
|
115
|
-
const temporaryPath = `${registryPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
116
|
-
try {
|
|
117
|
-
await writeFile(temporaryPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
|
|
118
|
-
await rename(temporaryPath, registryPath);
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
95
|
async function normalizeRegistryForWrite(entries) {
|
|
126
96
|
const normalized = [];
|
|
127
97
|
const seenPaths = new Set();
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
export interface NewBranchOptions {
|
|
2
|
+
startPoint?: string;
|
|
3
|
+
track?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export type WorktreeTarget = {
|
|
6
|
+
branch: string;
|
|
7
|
+
} | {
|
|
8
|
+
branch: string;
|
|
9
|
+
create: NewBranchOptions;
|
|
10
|
+
} | {
|
|
11
|
+
detachedAt: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Adds a worktree on a path no other process has claimed, leaving nothing it
|
|
15
|
+
* created behind on failure.
|
|
16
|
+
*
|
|
17
|
+
* A new branch is created first because that is the only part of `git worktree add -b`
|
|
18
|
+
* that writes the shared .git/config. Retrying it stays cheap and lets checkouts run in parallel.
|
|
19
|
+
*/
|
|
20
|
+
export declare function addWorktree(repoRoot: string, worktreePath: string, target: WorktreeTarget): Promise<void>;
|
|
21
|
+
/** Best-effort rollback of a worktree, and of the branch if this run created it. */
|
|
22
|
+
export declare function removeCreatedWorktree(repoRoot: string, worktreePath: string, createdBranch?: string): Promise<void>;
|
|
23
|
+
export declare function createBranch(repoRoot: string, branch: string, options?: NewBranchOptions): Promise<void>;
|
|
24
|
+
/** Best-effort rollback. Git refuses to delete a branch another worktree has checked out. */
|
|
25
|
+
export declare function deleteCreatedBranch(repoRoot: string, branch: string): Promise<void>;
|
|
1
26
|
export declare function removeWorktree(repoRoot: string, worktreePath: string): Promise<void>;
|
|
2
27
|
export declare function forceRemoveWorktree(repoRoot: string, worktreePath: string): Promise<void>;
|
|
3
28
|
export declare function deleteBranch(repoRoot: string, branch: string): Promise<void>;
|
|
@@ -1,15 +1,113 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, rm, rmdir } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
+
import { isAlreadyExistsError } from "../filesystem/fs-utils.js";
|
|
5
|
+
import { GIT_ENV, retryGitContention } from "../git/runner.js";
|
|
4
6
|
import { listWorktrees } from "../repository/worktrees.js";
|
|
5
7
|
const execFileAsync = promisify(execFile);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
const BRANCH_CONFIG_CLEANUP_TIMEOUT_MS = 1_000;
|
|
9
|
+
/**
|
|
10
|
+
* Adds a worktree on a path no other process has claimed, leaving nothing it
|
|
11
|
+
* created behind on failure.
|
|
12
|
+
*
|
|
13
|
+
* A new branch is created first because that is the only part of `git worktree add -b`
|
|
14
|
+
* that writes the shared .git/config. Retrying it stays cheap and lets checkouts run in parallel.
|
|
15
|
+
*/
|
|
16
|
+
export async function addWorktree(repoRoot, worktreePath, target) {
|
|
17
|
+
await claimWorktreePath(worktreePath);
|
|
18
|
+
if ("create" in target) {
|
|
19
|
+
try {
|
|
20
|
+
await createBranch(repoRoot, target.branch, target.create);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
await rmdir(worktreePath).catch(() => undefined);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const args = "branch" in target
|
|
28
|
+
? [target.branch]
|
|
29
|
+
: ["--detach", ...(target.detachedAt ? [target.detachedAt] : [])];
|
|
30
|
+
try {
|
|
31
|
+
// No forced locale: this runs the user's hooks. Contention errors in another
|
|
32
|
+
// language are not retried, and fall through to the rollback below.
|
|
33
|
+
await retryGitContention(() => execFileAsync("git", ["worktree", "add", worktreePath, ...args], {
|
|
34
|
+
cwd: repoRoot,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// Git can fail after registering the worktree, for example in a post-checkout hook.
|
|
39
|
+
await removeCreatedWorktree(repoRoot, worktreePath, "create" in target ? target.branch : undefined);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Best-effort rollback of a worktree, and of the branch if this run created it. */
|
|
44
|
+
export async function removeCreatedWorktree(repoRoot, worktreePath, createdBranch) {
|
|
45
|
+
await runLifecycleGit(repoRoot, [
|
|
46
|
+
"worktree",
|
|
47
|
+
"remove",
|
|
48
|
+
"--force",
|
|
49
|
+
worktreePath,
|
|
50
|
+
]).catch(() => undefined);
|
|
51
|
+
await rmdir(worktreePath).catch(() => undefined);
|
|
52
|
+
if (createdBranch)
|
|
53
|
+
await deleteCreatedBranch(repoRoot, createdBranch);
|
|
54
|
+
}
|
|
55
|
+
// Git accepts an empty target directory but does not claim it atomically, so
|
|
56
|
+
// without this, racing processes can both succeed on one path.
|
|
57
|
+
async function claimWorktreePath(worktreePath) {
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(worktreePath);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!isAlreadyExistsError(error))
|
|
63
|
+
throw error;
|
|
64
|
+
throw new Error(`target worktree path already exists: ${worktreePath}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function createBranch(repoRoot, branch, options = {}) {
|
|
68
|
+
const args = [
|
|
69
|
+
"branch",
|
|
70
|
+
...(options.track ? ["--track"] : []),
|
|
71
|
+
branch,
|
|
72
|
+
...(options.startPoint ? [options.startPoint] : []),
|
|
73
|
+
];
|
|
74
|
+
try {
|
|
75
|
+
await retryGitContention(async () => {
|
|
76
|
+
try {
|
|
77
|
+
await execFileAsync("git", args, { cwd: repoRoot, env: GIT_ENV });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Git creates the ref before writing tracking config, so this failure
|
|
81
|
+
// leaves a branch that only this call can have created.
|
|
82
|
+
if (isGitConfigLockError(error))
|
|
83
|
+
await forceDeleteBranch(repoRoot, branch);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (isGitConfigLockError(error))
|
|
90
|
+
await removeBranchConfig(repoRoot, branch).catch(() => undefined);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Best-effort rollback. Git refuses to delete a branch another worktree has checked out. */
|
|
95
|
+
export async function deleteCreatedBranch(repoRoot, branch) {
|
|
96
|
+
try {
|
|
97
|
+
await forceDeleteBranch(repoRoot, branch);
|
|
98
|
+
await removeBranchConfig(repoRoot, branch);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* rollback best effort */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// `git branch -D` only warns when it cannot update the config, and tracking
|
|
105
|
+
// config can be left partly written when `git branch` loses the lock midway.
|
|
106
|
+
async function removeBranchConfig(repoRoot, branch) {
|
|
107
|
+
await runLifecycleGit(repoRoot, ["config", "--remove-section", `branch.${branch}`], BRANCH_CONFIG_CLEANUP_TIMEOUT_MS);
|
|
108
|
+
}
|
|
8
109
|
export async function removeWorktree(repoRoot, worktreePath) {
|
|
9
|
-
await
|
|
10
|
-
cwd: repoRoot,
|
|
11
|
-
env: GIT_ENV,
|
|
12
|
-
});
|
|
110
|
+
await runLifecycleGit(repoRoot, ["worktree", "remove", worktreePath]);
|
|
13
111
|
}
|
|
14
112
|
export async function forceRemoveWorktree(repoRoot, worktreePath) {
|
|
15
113
|
try {
|
|
@@ -29,10 +127,12 @@ export async function forceRemoveWorktree(repoRoot, worktreePath) {
|
|
|
29
127
|
}
|
|
30
128
|
}
|
|
31
129
|
async function removeWorktreeWithForce(repoRoot, worktreePath) {
|
|
32
|
-
await
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
130
|
+
await runLifecycleGit(repoRoot, [
|
|
131
|
+
"worktree",
|
|
132
|
+
"remove",
|
|
133
|
+
"--force",
|
|
134
|
+
worktreePath,
|
|
135
|
+
]);
|
|
36
136
|
}
|
|
37
137
|
async function deinitializeSubmodules(worktreePath) {
|
|
38
138
|
await execFileAsync("git", ["submodule", "deinit", "--force", "--all"], {
|
|
@@ -41,16 +141,10 @@ async function deinitializeSubmodules(worktreePath) {
|
|
|
41
141
|
});
|
|
42
142
|
}
|
|
43
143
|
export async function deleteBranch(repoRoot, branch) {
|
|
44
|
-
await
|
|
45
|
-
cwd: repoRoot,
|
|
46
|
-
env: GIT_ENV,
|
|
47
|
-
});
|
|
144
|
+
await runLifecycleGit(repoRoot, ["branch", "-d", branch]);
|
|
48
145
|
}
|
|
49
146
|
export async function forceDeleteBranch(repoRoot, branch) {
|
|
50
|
-
await
|
|
51
|
-
cwd: repoRoot,
|
|
52
|
-
env: GIT_ENV,
|
|
53
|
-
});
|
|
147
|
+
await runLifecycleGit(repoRoot, ["branch", "-D", branch]);
|
|
54
148
|
}
|
|
55
149
|
export function isWorktreeForceRemovalError(error) {
|
|
56
150
|
if (!hasStderr(error)) {
|
|
@@ -75,3 +169,10 @@ function hasStderr(error) {
|
|
|
75
169
|
"stderr" in error &&
|
|
76
170
|
typeof error.stderr === "string");
|
|
77
171
|
}
|
|
172
|
+
async function runLifecycleGit(repoRoot, args, timeoutMs) {
|
|
173
|
+
await retryGitContention(() => execFileAsync("git", args, { cwd: repoRoot, env: GIT_ENV }), timeoutMs);
|
|
174
|
+
}
|
|
175
|
+
function isGitConfigLockError(error) {
|
|
176
|
+
return (error instanceof Error &&
|
|
177
|
+
error.message.includes("could not lock config file"));
|
|
178
|
+
}
|
package/dist/ports/git.d.ts
CHANGED
package/dist/ports/hooks.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export type GjiHookCommand = string | string[];
|
|
2
2
|
export interface GjiHooks {
|
|
3
|
+
/** A new branch was created by `gji new`, in a new worktree or in place. */
|
|
4
|
+
"after-branch-create"?: GjiHookCommand;
|
|
5
|
+
/** A linked worktree was created and bootstrapped. */
|
|
3
6
|
"after-create"?: GjiHookCommand;
|
|
7
|
+
/** An existing worktree was entered. */
|
|
4
8
|
"after-enter"?: GjiHookCommand;
|
|
9
|
+
/** The current worktree switched to another branch in place. */
|
|
10
|
+
"after-switch"?: GjiHookCommand;
|
|
11
|
+
/** A linked worktree is about to be removed. */
|
|
5
12
|
"before-remove"?: GjiHookCommand;
|
|
6
13
|
}
|
|
7
14
|
export interface HookContext {
|
|
@@ -37,23 +37,20 @@ const TOP_LEVEL_COMMANDS = [
|
|
|
37
37
|
},
|
|
38
38
|
{ name: "ls", description: "list active worktrees" },
|
|
39
39
|
{ name: "clean", description: "interactively prune linked worktrees" },
|
|
40
|
-
{
|
|
41
|
-
name: "remove",
|
|
42
|
-
description: "deprecated: use done for one worktree or clean for bulk cleanup",
|
|
43
|
-
},
|
|
44
|
-
{ name: "rm", description: "deprecated alias of remove" },
|
|
45
40
|
{
|
|
46
41
|
name: "run-hook",
|
|
47
42
|
description: "run a named hook in the current worktree",
|
|
48
43
|
},
|
|
49
|
-
{
|
|
50
|
-
name: "warp",
|
|
51
|
-
description: "deprecated: use go (press Tab for all known repos)",
|
|
52
|
-
},
|
|
53
44
|
{ name: "config", description: "manage global config defaults" },
|
|
54
45
|
];
|
|
55
46
|
const SHELL_NAMES = ["bash", "fish", "zsh"];
|
|
56
|
-
const HOOK_NAMES = [
|
|
47
|
+
const HOOK_NAMES = [
|
|
48
|
+
"after-branch-create",
|
|
49
|
+
"after-create",
|
|
50
|
+
"after-enter",
|
|
51
|
+
"after-switch",
|
|
52
|
+
"before-remove",
|
|
53
|
+
];
|
|
57
54
|
const CONFIG_KEYS = Array.from(KNOWN_GLOBAL_CONFIG_KEYS);
|
|
58
55
|
export function renderShellCompletion(shell) {
|
|
59
56
|
switch (shell) {
|
|
@@ -227,15 +224,9 @@ _gji_completion() {
|
|
|
227
224
|
COMPREPLY=( $(compgen -W "-f --force --stale --path --dry-run --json --help" -- "$cur") )
|
|
228
225
|
fi
|
|
229
226
|
;;
|
|
230
|
-
remove|rm)
|
|
231
|
-
COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) -f --force --dry-run --json --help" -- "$cur") )
|
|
232
|
-
;;
|
|
233
227
|
run-hook)
|
|
234
228
|
COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
|
|
235
229
|
;;
|
|
236
|
-
warp)
|
|
237
|
-
COMPREPLY=( $(compgen -W "--print --json --help" -- "$cur") )
|
|
238
|
-
;;
|
|
239
230
|
config)
|
|
240
231
|
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
241
232
|
COMPREPLY=( $(compgen -W "get set unset" -- "$cur") )
|
|
@@ -462,18 +453,10 @@ complete -c gji -n '__fish_seen_subcommand_from clean' -l path -r -F -d 'only ta
|
|
|
462
453
|
complete -c gji -n '__fish_seen_subcommand_from clean' -l dry-run -d 'show what would be deleted without removing anything'
|
|
463
454
|
complete -c gji -n '__fish_seen_subcommand_from clean' -l json -d 'emit JSON on success or error instead of human-readable output'
|
|
464
455
|
|
|
465
|
-
complete -c gji -n '__fish_seen_subcommand_from remove rm' -s f -l force -d 'bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch'
|
|
466
|
-
complete -c gji -n '__fish_seen_subcommand_from remove rm' -l dry-run -d 'show what would be deleted without removing anything'
|
|
467
|
-
complete -c gji -n '__fish_seen_subcommand_from remove rm' -l json -d 'emit JSON on success or error instead of human-readable output'
|
|
468
|
-
complete -c gji -n '__fish_seen_subcommand_from remove rm' -a '(__gji_worktree_branches)' -d 'worktree branch'
|
|
469
|
-
|
|
470
456
|
${hookLines}
|
|
471
457
|
|
|
472
458
|
complete -c gji -n '__fish_use_subcommand' -l json -d 'emit the repository hub as JSON'
|
|
473
459
|
|
|
474
|
-
complete -c gji -n '__fish_seen_subcommand_from warp' -l print -d 'print the resolved worktree path without changing directory'
|
|
475
|
-
complete -c gji -n '__fish_seen_subcommand_from warp' -l json -d 'emit JSON on success or error instead of human-readable output'
|
|
476
|
-
|
|
477
460
|
complete -c gji -n '__fish_seen_subcommand_from config; and __gji_should_complete_config_action' -a 'get set unset' -d 'config action'
|
|
478
461
|
${configKeyLines}`;
|
|
479
462
|
}
|
|
@@ -619,15 +602,9 @@ case "\${words[2]}" in
|
|
|
619
602
|
clean)
|
|
620
603
|
_arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches]' '--stale[only target clean worktrees whose upstream is gone and branch is merged into the default branch]' '*--path[only target worktrees under a directory or matching a path glob]:pattern:_files' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]'
|
|
621
604
|
;;
|
|
622
|
-
remove|rm)
|
|
623
|
-
_arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch:->worktrees'
|
|
624
|
-
;;
|
|
625
605
|
run-hook)
|
|
626
606
|
_arguments "2:hook:(${hooks})"
|
|
627
607
|
;;
|
|
628
|
-
warp)
|
|
629
|
-
_arguments '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
|
|
630
|
-
;;
|
|
631
608
|
config)
|
|
632
609
|
if (( CURRENT == 3 )); then
|
|
633
610
|
_values 'config action' get set unset
|
|
@@ -12,5 +12,5 @@ function formatState(info) {
|
|
|
12
12
|
return `${info.status}${info.upstream.kind === "tracked" ? ` · ↑${info.upstream.ahead} ↓${info.upstream.behind}` : ""}`;
|
|
13
13
|
}
|
|
14
14
|
function formatLastCommit(info) {
|
|
15
|
-
return new Date(info.lastCommitTimestamp).toISOString();
|
|
15
|
+
return new Date(info.lastCommitTimestamp * 1000).toISOString();
|
|
16
16
|
}
|
package/man/man1/gji-back.1
CHANGED
package/man/man1/gji-clean.1
CHANGED