@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
156 lines (155 loc) • 7.8 kB
JavaScript
import { resolveStoreConflicts } from "./conflict-resolve.js";
import { gitOperationRecovery, inProgressGitOperation, storeAutoCommitBlocker } from "./git-state.js";
import { storeCommitMessage } from "../machine-identity.js";
/** Pre-merge HEADs, one ref per diverged merge, named by the time it was taken (store-cleanup.ts prunes them). */
export const BACKUP_REF_PREFIX = "refs/phren/backup/";
/** The staged diff, added lines only, for `storeAutoCommitBlocker`. */
export const STAGED_DIFF = ["diff", "--cached", "--no-color", "--no-ext-diff", "-U0"];
const SENSITIVE_STORE_PATHSPECS = [".env", "**/.env", "*.pem", "*.key", ".config/auth-profiles.json"];
async function commitLocalStoreWrites(cwd, git, message) {
const add = await git(cwd, ["add", "--sparse", "-A"]);
if (!add.ok)
return { committed: false, error: add.error || "git add failed" };
await git(cwd, ["reset", "HEAD", "--", ...SENSITIVE_STORE_PATHSPECS]);
const staged = await git(cwd, ["diff", "--cached", "--name-only"]);
if (!staged.ok)
return { committed: false, error: staged.error || "git diff failed" };
if (!staged.output)
return { committed: false };
const blocked = await storeAutoCommitBlocker(cwd, () => git(cwd, STAGED_DIFF));
if (blocked)
return { committed: false, error: blocked };
const commit = await git(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", storeCommitMessage(message)]);
return commit.ok ? { committed: true } : { committed: false, error: commit.error || "git commit failed" };
}
async function conflictedPaths(cwd, git) {
const result = await git(cwd, ["diff", "--name-only", "--diff-filter=U"]);
return result.ok ? result.output.split("\n").map((entry) => entry.trim()).filter(Boolean) : [];
}
async function abortMerge(cwd, git) {
if (inProgressGitOperation(cwd) !== "merge")
return true;
const aborted = await git(cwd, ["merge", "--abort"]);
if (aborted.ok)
return true;
const reset = await git(cwd, ["reset", "--merge", "ORIG_HEAD"]);
return reset.ok && inProgressGitOperation(cwd) !== "merge";
}
/**
* Fetch and integrate a store's configured upstream without rebasing local
* commits. Conflicts in tasks.md (per task id against the merge base), the
* generated summary and topic blocks, findings and the task archive resolve
* automatically (see conflict-resolve.ts). Any other conflict aborts the
* merge and names its files.
*/
export async function mergeStoreUpstream(cwd, options) {
const { git } = options;
const operation = inProgressGitOperation(cwd);
if (operation) {
const recovery = gitOperationRecovery(operation);
return {
status: "busy",
detail: `The store already has a Git operation in progress (${operation}). Run '${recovery}' in the store before syncing again.`,
};
}
const branch = await git(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
if (!branch.ok)
return { status: "error", detail: "No branch is checked out." };
let committedLocalWrites = false;
if (options.commitLocalWrites !== false) {
const saved = await commitLocalStoreWrites(cwd, git, options.commitMessage ?? "auto-save phren (store sync)");
if (saved.error)
return { status: "error", detail: `Cannot commit local store writes: ${saved.error}` };
committedLocalWrites = saved.committed;
}
const refs = await git(cwd, ["for-each-ref", "--format=%(upstream:remotename)%09%(upstream:remoteref)%09%(upstream)", branch.output]);
const [remote, remoteRef, trackingRef] = refs.output.split("\t");
if (!refs.ok || !remote || !remoteRef || !trackingRef) {
return {
status: "unchanged",
detail: committedLocalWrites ? "Committed local store writes; no tracking remote is configured." : "No tracking remote is configured.",
committedLocalWrites,
};
}
const tracked = await git(cwd, ["rev-parse", "--verify", trackingRef]);
if (!options.advertisedHead || !tracked.ok || tracked.output !== options.advertisedHead) {
const fetched = await git(cwd, ["fetch", "--quiet", "--no-tags", "--no-recurse-submodules", "--", remote, `${remoteRef}:${trackingRef}`]);
if (!fetched.ok)
return { status: "error", detail: `Fetch failed: ${fetched.error || "unknown Git error"}`, committedLocalWrites };
}
const target = await git(cwd, ["rev-parse", "--verify", trackingRef]);
if (!target.ok || !target.output)
return { status: "error", detail: "Cannot read the fetched tracking branch.", committedLocalWrites };
if ((await git(cwd, ["merge-base", "--is-ancestor", target.output, "HEAD"])).ok) {
return {
status: "unchanged",
detail: committedLocalWrites ? "Committed local store writes; the store already contains the remote changes." : "The store already contains the remote changes.",
committedLocalWrites,
};
}
const canFastForward = (await git(cwd, ["merge-base", "--is-ancestor", "HEAD", target.output])).ok;
// Both sides moved: keep the local history reachable before merging into it.
let backupRef;
if (!canFastForward) {
const ref = `${BACKUP_REF_PREFIX}${Date.now()}`;
if (!(await git(cwd, ["update-ref", ref, "HEAD"])).ok) {
return { status: "error", detail: "Cannot keep a backup of the store's history before merging.", committedLocalWrites };
}
backupRef = ref;
}
const merged = await git(cwd, canFastForward
? ["merge", "--ff-only", target.output]
: ["-c", "commit.gpgsign=false", "merge", "--no-edit", target.output]);
if (merged.ok) {
return {
status: "updated",
detail: canFastForward ? "Store fast-forwarded to the remote." : "Store merged with the remote.",
committedLocalWrites,
...(backupRef ? { backupRef } : {}),
};
}
const initialConflicts = await conflictedPaths(cwd, git);
if (initialConflicts.length === 0 && inProgressGitOperation(cwd) !== "merge") {
return {
status: "error",
detail: `Merge failed before it started: ${merged.error || "unknown Git error"}`,
committedLocalWrites,
};
}
if (initialConflicts.length > 0 && resolveStoreConflicts(cwd).unresolved.length === 0) {
const commit = await git(cwd, ["-c", "commit.gpgsign=false", "commit", "--no-edit"]);
if (commit.ok) {
return {
status: "updated",
detail: `Store merged with the remote; resolved conflicts in ${initialConflicts.join(", ")}.`,
conflicts: initialConflicts,
committedLocalWrites,
...(backupRef ? { backupRef } : {}),
};
}
}
const unresolvedConflicts = await conflictedPaths(cwd, git);
const conflicts = unresolvedConflicts.length > 0 ? unresolvedConflicts : initialConflicts;
const cleaned = await abortMerge(cwd, git);
if (!cleaned) {
return {
status: "error",
detail: `Merge cleanup failed${conflicts.length ? ` after conflicts in: ${conflicts.join(", ")}` : ""}. Run 'git merge --abort' in the store.`,
conflicts,
committedLocalWrites,
};
}
if (conflicts.length > 0) {
return {
status: "conflict",
detail: `Merge aborted; manual resolution is required for: ${conflicts.join(", ")}`,
conflicts,
committedLocalWrites,
};
}
return {
status: "error",
detail: `Merge failed and was aborted: ${merged.error || "unknown Git error"}`,
committedLocalWrites,
};
}