@samaksh/git-worker 0.0.0-stage → 0.1.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/GIT_WORKER_PROJECT_PLAN.md +499 -0
- package/README.md +114 -2
- package/demo/README.md +58 -0
- package/demo/index.ts +526 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +124 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +44 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +250 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +48 -4
- package/src/app.ts +69 -0
- package/src/cli/commands.ts +142 -0
- package/src/cli/index.ts +123 -0
- package/src/config/defaults.ts +46 -0
- package/src/config/load.ts +70 -0
- package/src/config/schema.ts +48 -0
- package/src/core/errors.ts +45 -0
- package/src/core/pipeline.ts +17 -0
- package/src/core/types.ts +188 -0
- package/src/services/editor/index.ts +85 -0
- package/src/services/git/ops.ts +170 -0
- package/src/services/git/reader.ts +148 -0
- package/src/services/report/index.ts +104 -0
- package/src/services/review/index.ts +80 -0
- package/src/services/state/index.ts +77 -0
- package/src/services/template/env.ts +169 -0
- package/src/services/template/interpolator.ts +109 -0
- package/src/services/template/transform.ts +97 -0
- package/src/services/template/validator.ts +75 -0
- package/src/services/workspace/cache.ts +144 -0
- package/src/services/workspace/manager.ts +370 -0
- package/src/stages/apply.ts +194 -0
- package/src/stages/edit-template.ts +227 -0
- package/src/stages/eligibility.ts +67 -0
- package/src/stages/load-template.ts +188 -0
- package/src/stages/plan.ts +362 -0
- package/src/stages/push.ts +120 -0
- package/src/stages/report.ts +126 -0
- package/src/stages/resolve-project.ts +341 -0
- package/src/stages/review.ts +228 -0
- package/src/stages/select.ts +302 -0
- package/src/stages/sync.ts +63 -0
- package/src/utils/hash.ts +15 -0
- package/src/utils/ui.ts +137 -0
- package/src/utils/which.ts +18 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { readdir, readFile, rm, mkdir } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { ok, err } from "../../core/types.js";
|
|
5
|
+
import { workspaceError } from "../../core/errors.js";
|
|
6
|
+
import { getWorkspaceRoot, extractRepoName } from "./manager.js";
|
|
7
|
+
export { extractRepoName };
|
|
8
|
+
/**
|
|
9
|
+
* List all cached projects in the git-worker workspace root (~/.git-worker/projects)
|
|
10
|
+
*/
|
|
11
|
+
export async function listCachedProjects(config) {
|
|
12
|
+
const root = getWorkspaceRoot(config);
|
|
13
|
+
const projectsDir = join(root, "projects");
|
|
14
|
+
if (!existsSync(projectsDir)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const entries = await readdir(projectsDir, { withFileTypes: true });
|
|
19
|
+
const projects = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!entry.isDirectory())
|
|
22
|
+
continue;
|
|
23
|
+
const projectDir = join(projectsDir, entry.name);
|
|
24
|
+
const markerPath = join(projectDir, ".git-worker-marker");
|
|
25
|
+
if (existsSync(markerPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(markerPath, "utf-8");
|
|
28
|
+
const data = JSON.parse(raw);
|
|
29
|
+
const hasRepo = existsSync(join(projectDir, "repo"));
|
|
30
|
+
const name = data.name ||
|
|
31
|
+
extractRepoName(data.remoteUrl || data.sourceRepoPath, entry.name);
|
|
32
|
+
projects.push({
|
|
33
|
+
id: data.id || entry.name,
|
|
34
|
+
name,
|
|
35
|
+
sourceRepoPath: data.sourceRepoPath || "",
|
|
36
|
+
remoteUrl: data.remoteUrl || undefined,
|
|
37
|
+
createdAt: data.createdAt || "",
|
|
38
|
+
gitWorkerVersion: data.gitWorkerVersion,
|
|
39
|
+
projectDir,
|
|
40
|
+
hasRepo,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// malformed marker, still list with id
|
|
45
|
+
projects.push({
|
|
46
|
+
id: entry.name,
|
|
47
|
+
name: entry.name,
|
|
48
|
+
sourceRepoPath: "",
|
|
49
|
+
projectDir,
|
|
50
|
+
createdAt: "",
|
|
51
|
+
hasRepo: existsSync(join(projectDir, "repo")),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Clear a specific project cache by project ID, name, remote URL, or path.
|
|
64
|
+
*/
|
|
65
|
+
export async function clearProjectCache(target, config) {
|
|
66
|
+
const projects = await listCachedProjects(config);
|
|
67
|
+
const trimmed = target.trim();
|
|
68
|
+
const match = projects.find((p) => p.id === trimmed ||
|
|
69
|
+
p.id.startsWith(trimmed) ||
|
|
70
|
+
p.name.toLowerCase() === trimmed.toLowerCase() ||
|
|
71
|
+
(p.remoteUrl && (p.remoteUrl === trimmed || p.remoteUrl.includes(trimmed))) ||
|
|
72
|
+
p.sourceRepoPath === trimmed ||
|
|
73
|
+
p.sourceRepoPath.includes(trimmed));
|
|
74
|
+
if (!match) {
|
|
75
|
+
return err(workspaceError(`No cached project found matching '${target}'. Run 'git-worker cache list' to view all cached projects.`));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await rm(match.projectDir, { recursive: true, force: true });
|
|
79
|
+
return ok({
|
|
80
|
+
removed: `${match.name} (${match.remoteUrl || match.sourceRepoPath || match.id})`,
|
|
81
|
+
projectDir: match.projectDir,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return err(workspaceError(`Failed to remove project cache: ${e.message}`, e));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear all cached projects and workspaces in ~/.git-worker/projects.
|
|
90
|
+
*/
|
|
91
|
+
export async function clearAllCache(config) {
|
|
92
|
+
const root = getWorkspaceRoot(config);
|
|
93
|
+
const projectsDir = join(root, "projects");
|
|
94
|
+
if (!existsSync(projectsDir)) {
|
|
95
|
+
return ok({ count: 0, root: projectsDir });
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const projects = await listCachedProjects(config);
|
|
99
|
+
const count = projects.length;
|
|
100
|
+
await rm(projectsDir, { recursive: true, force: true });
|
|
101
|
+
await mkdir(projectsDir, { recursive: true });
|
|
102
|
+
return ok({ count, root: projectsDir });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return err(workspaceError(`Failed to clear cache: ${e.message}`, e));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Result, ManagedProject } from "../../core/types.js";
|
|
2
|
+
import { AppConfig } from "../../config/schema.js";
|
|
3
|
+
export type RepoInfo = {
|
|
4
|
+
isRepo: boolean;
|
|
5
|
+
remoteUrl?: string;
|
|
6
|
+
remoteName?: string;
|
|
7
|
+
};
|
|
8
|
+
export type WorkspaceManager = {
|
|
9
|
+
resolveProject(sourceRepoPath: string, remote: string | undefined, config: AppConfig): Promise<Result<ManagedProject>>;
|
|
10
|
+
checkLocalRepo(dirPath: string, preferredRemote?: string): Promise<RepoInfo>;
|
|
11
|
+
ensureManagedClone(project: ManagedProject): Promise<Result<{
|
|
12
|
+
isCached: boolean;
|
|
13
|
+
}>>;
|
|
14
|
+
verifyOwnership(project: ManagedProject): Result<void>;
|
|
15
|
+
acquireLock(project: ManagedProject, timeoutMs: number): Promise<Result<() => void>>;
|
|
16
|
+
releaseLock(project: ManagedProject): void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Extract a human-readable repository name from a remote URL or file path.
|
|
20
|
+
* e.g. "https://github.com/org/repo.git" -> "repo"
|
|
21
|
+
* e.g. "git@github.com:org/repo.git" -> "repo"
|
|
22
|
+
* e.g. "/path/to/my-repo" -> "my-repo"
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractRepoName(urlOrPath?: string, fallbackId?: string): string;
|
|
25
|
+
export declare function getWorkspaceRoot(config?: AppConfig): string;
|
|
26
|
+
/**
|
|
27
|
+
* Verify that the given path is inside our workspace and the marker file is valid.
|
|
28
|
+
* This is the ownership guard that must run before any destructive ops.
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyOwnership(project: ManagedProject): Result<void>;
|
|
31
|
+
export declare function checkLocalRepo(dirPath: string, preferredRemote?: string): Promise<RepoInfo>;
|
|
32
|
+
export declare function resolveProject(sourceRepoPath: string, remote: string | undefined, config: AppConfig): Promise<Result<ManagedProject>>;
|
|
33
|
+
export declare function ensureManagedClone(project: ManagedProject): Promise<Result<{
|
|
34
|
+
isCached: boolean;
|
|
35
|
+
}>>;
|
|
36
|
+
export declare function releaseLock(project: ManagedProject): void;
|
|
37
|
+
/**
|
|
38
|
+
* Acquire a file-based lock on the managed checkout.
|
|
39
|
+
* Returns a release function to call when done.
|
|
40
|
+
* Checks for and cleans up stale locks from terminated processes.
|
|
41
|
+
* Waits up to timeoutMs for the lock to become available.
|
|
42
|
+
*/
|
|
43
|
+
export declare function acquireLock(project: ManagedProject, timeoutMs: number): Promise<Result<() => void>>;
|
|
44
|
+
export declare function createWorkspaceManager(): WorkspaceManager;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { ok, err } from "../../core/types.js";
|
|
2
|
+
import { workspaceError, gitError } from "../../core/errors.js";
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
4
|
+
import { mkdir, writeFile, rm, open } from "fs/promises";
|
|
5
|
+
import { join, resolve, normalize } from "path";
|
|
6
|
+
import { simpleGit, CheckRepoActions } from "simple-git";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
const GIT_WORKER_VERSION = "0.1.0";
|
|
10
|
+
const LOCK_FILE = ".git-worker-lock";
|
|
11
|
+
/**
|
|
12
|
+
* Extract a human-readable repository name from a remote URL or file path.
|
|
13
|
+
* e.g. "https://github.com/org/repo.git" -> "repo"
|
|
14
|
+
* e.g. "git@github.com:org/repo.git" -> "repo"
|
|
15
|
+
* e.g. "/path/to/my-repo" -> "my-repo"
|
|
16
|
+
*/
|
|
17
|
+
export function extractRepoName(urlOrPath, fallbackId) {
|
|
18
|
+
if (!urlOrPath)
|
|
19
|
+
return fallbackId || "unnamed-repo";
|
|
20
|
+
const clean = urlOrPath.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
21
|
+
const lastSlash = Math.max(clean.lastIndexOf("/"), clean.lastIndexOf(":"), clean.lastIndexOf("\\"));
|
|
22
|
+
if (lastSlash >= 0 && lastSlash < clean.length - 1) {
|
|
23
|
+
return clean.slice(lastSlash + 1);
|
|
24
|
+
}
|
|
25
|
+
return clean || fallbackId || "unnamed-repo";
|
|
26
|
+
}
|
|
27
|
+
export function getWorkspaceRoot(config) {
|
|
28
|
+
// Use config.workspace.root if it looks absolute or explicitly specified
|
|
29
|
+
const root = config?.workspace?.root;
|
|
30
|
+
if (root && (root.startsWith("/") || root.startsWith("~"))) {
|
|
31
|
+
return root.replace(/^~/, homedir());
|
|
32
|
+
}
|
|
33
|
+
// Store directly in ~/.git-worker in user's home directory
|
|
34
|
+
return join(homedir(), ".git-worker");
|
|
35
|
+
}
|
|
36
|
+
function projectIdFromRemote(remoteUrl) {
|
|
37
|
+
return createHash("sha256").update(remoteUrl).digest("hex").slice(0, 16);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify that the given path is inside our workspace and the marker file is valid.
|
|
41
|
+
* This is the ownership guard that must run before any destructive ops.
|
|
42
|
+
*/
|
|
43
|
+
export function verifyOwnership(project) {
|
|
44
|
+
if (!existsSync(project.markerPath)) {
|
|
45
|
+
return err(workspaceError(`Ownership marker not found at '${project.markerPath}'. Refusing destructive operation.`));
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(project.markerPath, "utf-8");
|
|
49
|
+
const marker = JSON.parse(raw);
|
|
50
|
+
if (marker.id !== project.id) {
|
|
51
|
+
return err(workspaceError(`Marker ID mismatch: expected '${project.id}', found '${marker.id}'. Refusing destructive operation.`));
|
|
52
|
+
}
|
|
53
|
+
// Path escape check: managed repo must be inside the project dir
|
|
54
|
+
const projectDir = join(project.managedRepoPath, "..");
|
|
55
|
+
const normalizedRepo = normalize(resolve(project.managedRepoPath));
|
|
56
|
+
const normalizedProject = normalize(resolve(projectDir));
|
|
57
|
+
if (!normalizedRepo.startsWith(normalizedProject)) {
|
|
58
|
+
return err(workspaceError(`Managed repo path '${normalizedRepo}' escapes project directory '${normalizedProject}'.`));
|
|
59
|
+
}
|
|
60
|
+
return ok(undefined);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return err(workspaceError(`Failed to read ownership marker: ${e.message}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function checkLocalRepo(dirPath, preferredRemote = "origin") {
|
|
67
|
+
try {
|
|
68
|
+
const resolved = resolve(dirPath);
|
|
69
|
+
const git = simpleGit(resolved);
|
|
70
|
+
let isRepo = await git.checkIsRepo().catch(() => false);
|
|
71
|
+
if (!isRepo) {
|
|
72
|
+
isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
|
|
73
|
+
}
|
|
74
|
+
if (!isRepo) {
|
|
75
|
+
return { isRepo: false };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const remotes = await git.getRemotes(true);
|
|
79
|
+
const target = remotes.find((r) => r.name === preferredRemote) || remotes[0];
|
|
80
|
+
if (target?.refs?.fetch) {
|
|
81
|
+
return { isRepo: true, remoteUrl: target.refs.fetch, remoteName: target.name };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// no remotes
|
|
86
|
+
}
|
|
87
|
+
return { isRepo: true };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { isRepo: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function resolveProject(sourceRepoPath, remote, config) {
|
|
94
|
+
try {
|
|
95
|
+
// Check if this looks like a remote URL (http/git/ssh)
|
|
96
|
+
const isRemoteUrl = sourceRepoPath.startsWith("http://") ||
|
|
97
|
+
sourceRepoPath.startsWith("https://") ||
|
|
98
|
+
sourceRepoPath.startsWith("git@") ||
|
|
99
|
+
sourceRepoPath.startsWith("ssh://") ||
|
|
100
|
+
sourceRepoPath.startsWith("git://");
|
|
101
|
+
const sourceLocation = isRemoteUrl ? sourceRepoPath : resolve(sourceRepoPath);
|
|
102
|
+
let remoteUrl;
|
|
103
|
+
if (isRemoteUrl) {
|
|
104
|
+
// Treat the supplied path directly as the remote URL
|
|
105
|
+
remoteUrl = sourceRepoPath;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// It's a local path — verify it's a git repo and discover its remote
|
|
109
|
+
const git = simpleGit(sourceLocation);
|
|
110
|
+
let isRepo = await git.checkIsRepo().catch(() => false);
|
|
111
|
+
if (!isRepo) {
|
|
112
|
+
isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
|
|
113
|
+
}
|
|
114
|
+
if (!isRepo) {
|
|
115
|
+
return err(gitError(`'${sourceLocation}' is not a git repository`));
|
|
116
|
+
}
|
|
117
|
+
const targetRemoteName = remote || config.git.remote;
|
|
118
|
+
try {
|
|
119
|
+
const remotes = await git.getRemotes(true);
|
|
120
|
+
const targetRemote = remotes.find((r) => r.name === targetRemoteName) || remotes[0];
|
|
121
|
+
if (targetRemote?.refs?.fetch) {
|
|
122
|
+
remoteUrl = targetRemote.refs.fetch;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
// If bare repo with no remotes: use its own path as the remote URL
|
|
129
|
+
if (!remoteUrl) {
|
|
130
|
+
const isBare = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
|
|
131
|
+
if (isBare) {
|
|
132
|
+
remoteUrl = sourceLocation;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!remoteUrl && config.safety.rejectNoRemote) {
|
|
137
|
+
return err(workspaceError(`No remote found in '${sourceLocation}'. ` +
|
|
138
|
+
`Git Worker requires a remote to create a safe managed checkout. ` +
|
|
139
|
+
`Disable reject-no-remote in config to allow local-only repos (not recommended).`));
|
|
140
|
+
}
|
|
141
|
+
const id = projectIdFromRemote(remoteUrl || sourceLocation);
|
|
142
|
+
const root = getWorkspaceRoot(config);
|
|
143
|
+
const projectDir = join(root, "projects", id);
|
|
144
|
+
const managedRepoPath = join(projectDir, "repo");
|
|
145
|
+
const stagingDir = join(projectDir, "staging");
|
|
146
|
+
const stateDir = join(projectDir, "state");
|
|
147
|
+
const reportsDir = join(projectDir, "reports");
|
|
148
|
+
const markerPath = join(projectDir, ".git-worker-marker");
|
|
149
|
+
await mkdir(projectDir, { recursive: true });
|
|
150
|
+
await mkdir(managedRepoPath, { recursive: true });
|
|
151
|
+
await mkdir(stagingDir, { recursive: true });
|
|
152
|
+
await mkdir(stateDir, { recursive: true });
|
|
153
|
+
await mkdir(reportsDir, { recursive: true });
|
|
154
|
+
const markerData = {
|
|
155
|
+
id,
|
|
156
|
+
name: extractRepoName(remoteUrl || sourceLocation, id),
|
|
157
|
+
sourceRepoPath: sourceLocation,
|
|
158
|
+
remoteUrl: remoteUrl || "",
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
gitWorkerVersion: GIT_WORKER_VERSION,
|
|
161
|
+
};
|
|
162
|
+
await writeFile(markerPath, JSON.stringify(markerData, null, 2), "utf-8");
|
|
163
|
+
// Check if an existing managed clone is already present for this remote URL
|
|
164
|
+
let isCached = false;
|
|
165
|
+
if (existsSync(managedRepoPath)) {
|
|
166
|
+
try {
|
|
167
|
+
const git = simpleGit(managedRepoPath);
|
|
168
|
+
const isRepo = await git.checkIsRepo().catch(() => false);
|
|
169
|
+
if (isRepo) {
|
|
170
|
+
const remotes = await git.getRemotes(true);
|
|
171
|
+
const origin = remotes.find((r) => r.name === "origin") || remotes[0];
|
|
172
|
+
if (origin?.refs?.fetch === remoteUrl) {
|
|
173
|
+
isCached = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// ignore
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return ok({
|
|
182
|
+
id,
|
|
183
|
+
sourceRepoPath: sourceLocation,
|
|
184
|
+
remoteUrl,
|
|
185
|
+
managedRepoPath,
|
|
186
|
+
stagingDir,
|
|
187
|
+
stateDir,
|
|
188
|
+
reportsDir,
|
|
189
|
+
markerPath,
|
|
190
|
+
isCached,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
return err(workspaceError(`Failed to resolve project: ${e.message}`, e));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export async function ensureManagedClone(project) {
|
|
198
|
+
try {
|
|
199
|
+
const git = simpleGit(project.managedRepoPath);
|
|
200
|
+
const isRepo = await git.checkIsRepo().catch(() => false);
|
|
201
|
+
if (isRepo) {
|
|
202
|
+
try {
|
|
203
|
+
const remotes = await git.getRemotes(true);
|
|
204
|
+
const origin = remotes.find((r) => r.name === "origin") || remotes[0];
|
|
205
|
+
if (origin?.refs?.fetch === project.remoteUrl) {
|
|
206
|
+
return ok({ isCached: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// remote mismatch, re-clone below
|
|
211
|
+
}
|
|
212
|
+
// Remote URL changed — clean and re-clone
|
|
213
|
+
await rm(project.managedRepoPath, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
if (!project.remoteUrl) {
|
|
216
|
+
return err(gitError("Cannot clone: no remote URL available"));
|
|
217
|
+
}
|
|
218
|
+
// Clone into 'repo' subdirectory
|
|
219
|
+
const parentGit = simpleGit(join(project.managedRepoPath, ".."));
|
|
220
|
+
await parentGit.clone(project.remoteUrl, "repo", ["--no-local"]);
|
|
221
|
+
return ok({ isCached: false });
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
return err(gitError(`Failed to clone managed repo: ${e.message}`, e));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function releaseLock(project) {
|
|
228
|
+
const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
|
|
229
|
+
try {
|
|
230
|
+
if (existsSync(lockPath)) {
|
|
231
|
+
unlinkSync(lockPath);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// best effort
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Acquire a file-based lock on the managed checkout.
|
|
240
|
+
* Returns a release function to call when done.
|
|
241
|
+
* Checks for and cleans up stale locks from terminated processes.
|
|
242
|
+
* Waits up to timeoutMs for the lock to become available.
|
|
243
|
+
*/
|
|
244
|
+
export async function acquireLock(project, timeoutMs) {
|
|
245
|
+
const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
const pid = process.pid;
|
|
248
|
+
while (Date.now() < deadline) {
|
|
249
|
+
// Check if existing lock is stale (process no longer exists)
|
|
250
|
+
if (existsSync(lockPath)) {
|
|
251
|
+
try {
|
|
252
|
+
const raw = readFileSync(lockPath, "utf-8");
|
|
253
|
+
const data = JSON.parse(raw);
|
|
254
|
+
if (data.pid && typeof data.pid === "number") {
|
|
255
|
+
try {
|
|
256
|
+
process.kill(data.pid, 0); // signal 0 tests process existence
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
if (err.code === "ESRCH") {
|
|
260
|
+
// Holding process died — remove stale lock immediately
|
|
261
|
+
releaseLock(project);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Malformed lock — clear it
|
|
268
|
+
releaseLock(project);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
// O_EXCL ensures atomic create — fails if file exists
|
|
273
|
+
const fh = await open(lockPath, "wx");
|
|
274
|
+
await fh.writeFile(JSON.stringify({ pid, acquiredAt: new Date().toISOString() }));
|
|
275
|
+
await fh.close();
|
|
276
|
+
const release = () => {
|
|
277
|
+
releaseLock(project);
|
|
278
|
+
};
|
|
279
|
+
return ok(release);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
if (e.code !== "EEXIST") {
|
|
283
|
+
return err(workspaceError(`Failed to acquire lock: ${e.message}`));
|
|
284
|
+
}
|
|
285
|
+
// Lock held by an active process — wait and retry
|
|
286
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return err(workspaceError(`Could not acquire workspace lock within ${timeoutMs}ms. ` +
|
|
290
|
+
`Another git-worker process may be running. Delete '${lockPath}' to force-release.`));
|
|
291
|
+
}
|
|
292
|
+
export function createWorkspaceManager() {
|
|
293
|
+
return {
|
|
294
|
+
resolveProject,
|
|
295
|
+
checkLocalRepo,
|
|
296
|
+
ensureManagedClone,
|
|
297
|
+
verifyOwnership,
|
|
298
|
+
acquireLock,
|
|
299
|
+
releaseLock,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ok, err } from "../core/types.js";
|
|
2
|
+
import { workspaceError } from "../core/errors.js";
|
|
3
|
+
import { clack, sectionHeader, statusLine } from "../utils/ui.js";
|
|
4
|
+
import { sha256 } from "../utils/hash.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
export const applyChangesStage = async (context, deps) => {
|
|
7
|
+
if (!context.plans || !context.approvals || !context.project) {
|
|
8
|
+
return err(workspaceError("Prerequisites missing for apply"));
|
|
9
|
+
}
|
|
10
|
+
sectionHeader("Applying Changes");
|
|
11
|
+
const results = [];
|
|
12
|
+
const approvedPlans = context.plans.filter((p) => {
|
|
13
|
+
const approval = context.approvals.find((a) => a.branchName === p.branch.name);
|
|
14
|
+
return approval?.approved === true;
|
|
15
|
+
});
|
|
16
|
+
// Handle unapproved/error plans first
|
|
17
|
+
for (const plan of context.plans) {
|
|
18
|
+
const approval = context.approvals.find((a) => a.branchName === plan.branch.name);
|
|
19
|
+
if (approval?.approved)
|
|
20
|
+
continue; // Will be handled below
|
|
21
|
+
if (plan.status === "unchanged") {
|
|
22
|
+
results.push({ branch: plan.branch, status: "unchanged", reason: "No changes" });
|
|
23
|
+
}
|
|
24
|
+
else if (plan.status === "error") {
|
|
25
|
+
results.push({ branch: plan.branch, status: "failed", reason: plan.reason });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
results.push({
|
|
29
|
+
branch: plan.branch,
|
|
30
|
+
status: approval ? "rejected" : "skipped",
|
|
31
|
+
reason: approval ? "User rejected" : "Not reviewed",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (approvedPlans.length === 0) {
|
|
36
|
+
statusLine("info", "No approved changes to apply");
|
|
37
|
+
return ok({ ...context, results });
|
|
38
|
+
}
|
|
39
|
+
for (const plan of approvedPlans) {
|
|
40
|
+
const s = clack.spinner();
|
|
41
|
+
s.start(`Applying ${plan.branch.name}…`);
|
|
42
|
+
try {
|
|
43
|
+
// Verify ownership before any destructive op
|
|
44
|
+
const ownerCheck = deps.services.workspace.verifyOwnership(context.project);
|
|
45
|
+
if (!ownerCheck.ok) {
|
|
46
|
+
s.stop(chalk.red(`${plan.branch.name} — ownership verification failed`));
|
|
47
|
+
results.push({ branch: plan.branch, status: "failed", reason: ownerCheck.error.message });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// ── Stale-plan guard ────────────────────────────────────────────
|
|
51
|
+
// Fetch latest SHA and verify it matches the reviewed plan
|
|
52
|
+
const currentShaResult = await deps.services.gitOps.getBranchSha(context.project.managedRepoPath, plan.branch.isRemote && plan.branch.remote
|
|
53
|
+
? `${plan.branch.remote}/${plan.branch.name}`
|
|
54
|
+
: plan.branch.name);
|
|
55
|
+
if (!currentShaResult.ok) {
|
|
56
|
+
s.stop(chalk.red(`${plan.branch.name} — could not verify branch tip`));
|
|
57
|
+
results.push({ branch: plan.branch, status: "failed", reason: "Could not verify branch tip" });
|
|
58
|
+
// Clean up before next branch
|
|
59
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
60
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (currentShaResult.value !== plan.baseCommitSha) {
|
|
64
|
+
s.stop(chalk.yellow(`${plan.branch.name} — branch advanced since review (stale plan)`));
|
|
65
|
+
results.push({
|
|
66
|
+
branch: plan.branch,
|
|
67
|
+
status: "failed",
|
|
68
|
+
reason: `Branch advanced: reviewed at ${plan.baseCommitSha.slice(0, 8)}, now at ${currentShaResult.value.slice(0, 8)}. Re-run to replan.`,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// ── Clean before checkout ───────────────────────────────────────
|
|
73
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath);
|
|
74
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
|
|
75
|
+
// ── Checkout branch ─────────────────────────────────────────────
|
|
76
|
+
const checkoutResult = await deps.services.gitOps.checkoutBranch(context.project.managedRepoPath, plan.branch.name);
|
|
77
|
+
if (!checkoutResult.ok) {
|
|
78
|
+
s.stop(chalk.red(`${plan.branch.name} — checkout failed`));
|
|
79
|
+
results.push({ branch: plan.branch, status: "failed", reason: checkoutResult.error.message });
|
|
80
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
81
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// ── Clean after checkout ─────────────────────────────────────────
|
|
85
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath);
|
|
86
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
|
|
87
|
+
// ── Verify before-content hash ──────────────────────────────────
|
|
88
|
+
const currentFileResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, plan.branch, plan.path);
|
|
89
|
+
const currentContent = currentFileResult.ok ? currentFileResult.value : "";
|
|
90
|
+
const currentHash = sha256(currentContent);
|
|
91
|
+
if (currentHash !== plan.beforeContentHash) {
|
|
92
|
+
s.stop(chalk.yellow(`${plan.branch.name} — file content changed since review`));
|
|
93
|
+
results.push({
|
|
94
|
+
branch: plan.branch,
|
|
95
|
+
status: "failed",
|
|
96
|
+
reason: "File content changed since review. Re-run to replan.",
|
|
97
|
+
});
|
|
98
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
99
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// ── Write and stage ─────────────────────────────────────────────
|
|
103
|
+
const writeResult = await deps.services.gitOps.writeAndStage(context.project.managedRepoPath, plan.path, plan.afterContent);
|
|
104
|
+
if (!writeResult.ok) {
|
|
105
|
+
s.stop(chalk.red(`${plan.branch.name} — write failed`));
|
|
106
|
+
results.push({ branch: plan.branch, status: "failed", reason: writeResult.error.message });
|
|
107
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// ── Verify only the intended path is staged ─────────────────────
|
|
111
|
+
const stagedResult = await deps.services.gitOps.getStagedFiles(context.project.managedRepoPath);
|
|
112
|
+
if (stagedResult.ok) {
|
|
113
|
+
const staged = stagedResult.value;
|
|
114
|
+
const unexpected = staged.filter((f) => f !== plan.path);
|
|
115
|
+
if (unexpected.length > 0) {
|
|
116
|
+
s.stop(chalk.red(`${plan.branch.name} — unexpected staged files`));
|
|
117
|
+
results.push({
|
|
118
|
+
branch: plan.branch,
|
|
119
|
+
status: "failed",
|
|
120
|
+
reason: `Unexpected staged files: ${unexpected.join(", ")}. Aborting commit for safety.`,
|
|
121
|
+
});
|
|
122
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (staged.length === 0) {
|
|
126
|
+
// No-op commit — already matches
|
|
127
|
+
s.stop(chalk.gray(`${plan.branch.name} — no staged changes (already matches)`));
|
|
128
|
+
results.push({ branch: plan.branch, status: "unchanged", reason: "Content identical after write" });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── Commit ──────────────────────────────────────────────────────
|
|
133
|
+
const commitMsg = deps.config.git.commitMessage;
|
|
134
|
+
const commitResult = await deps.services.gitOps.commit(context.project.managedRepoPath, commitMsg);
|
|
135
|
+
if (!commitResult.ok) {
|
|
136
|
+
s.stop(chalk.red(`${plan.branch.name} — commit failed`));
|
|
137
|
+
results.push({ branch: plan.branch, status: "failed", reason: commitResult.error.message });
|
|
138
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
s.stop(chalk.green(`${plan.branch.name} — committed ${commitResult.value.slice(0, 8)}`));
|
|
142
|
+
results.push({
|
|
143
|
+
branch: plan.branch,
|
|
144
|
+
status: "committed",
|
|
145
|
+
commitSha: commitResult.value,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
s.stop(chalk.red(`${plan.branch.name} — unexpected error`));
|
|
150
|
+
results.push({ branch: plan.branch, status: "failed", reason: e.message });
|
|
151
|
+
// Clean up managed checkout before next branch
|
|
152
|
+
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
153
|
+
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const committed = results.filter((r) => r.status === "committed").length;
|
|
157
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
158
|
+
console.log("");
|
|
159
|
+
statusLine(committed > 0 ? "ok" : "info", `Apply complete — ${committed} committed, ${failed} failed`);
|
|
160
|
+
return ok({ ...context, results });
|
|
161
|
+
};
|