singularity-forge/src/resources/extensions/sf/worktree.js
Mikael Hugo 0ece0e5413 refactor(sf-ext): consolidate sfHome, counters, tool helpers, settings path, post-mutation hook
- rf2-01: replace 23 inline `process.env.SF_HOME || join(homedir(), '.sf')` patterns
  across 19 files with canonical `sfHome()` from sf-home.js; removes 5 private
  sfHome/getSfHome function definitions and unused os/homedir imports
- rf2-05: extract `ensureWritableParent` and `errorMessage` from complete-task.js
  and complete-slice.js into new tools/tool-helpers.js
- rf2-06: add `runPostMutationHook` to tool-helpers.js; replace 8 identical
  try/catch blocks (plan-task, plan-slice, plan-milestone, replan-slice,
  reassess-roadmap, reopen-slice, reopen-task, reopen-milestone) with single call
- rf2-09: add `makeDiskCounter` factory in auto-dispatch.js; consolidate 4 counter
  functions (rewrite/uat get/set/increment) from duplicated if/else DB-vs-disk
  logic into thin factory wrappers (~35 lines removed)
- rf2-10: export `getSfAgentSettingsPath()` from preferences.js; update
  notifications/notify.js and permissions/permission-core.js to use it

All 4375 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 10:17:58 +02:00

294 lines
11 KiB
JavaScript

/**
* SF Worktree Utilities
*
* Pure utility functions for worktree name detection, legacy branch name
* parsing, and integration branch capture.
*
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain standalone for backwards compatibility.
*
* Branchless architecture: all work commits sequentially on the milestone branch.
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
*/
import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { join, resolve } from "node:path";
import { GitService, writeIntegrationBranch } from "./git-service.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { sfHome } from "./sf-home.js";
import { detectWorktreeName, findWorktreeSegment } from "./worktree-detect.js";
export { MergeConflictError } from "./git-service.js";
// Re-export for consumers that import detectWorktreeName from ./worktree.js
export { detectWorktreeName } from "./worktree-detect.js";
// ─── Lazy GitService Cache ─────────────────────────────────────────────
let cachedService = null;
let cachedBasePath = null;
/**
* Get or create a GitService for the given basePath.
* Resets the cache if basePath changes between calls.
* Lazy construction: only instantiated at call-time, never at module-evaluation.
*/
function getService(basePath) {
if (cachedService === null || cachedBasePath !== basePath) {
const loaded = loadEffectiveSFPreferences();
const gitPrefs = loaded?.preferences?.git ?? {};
cachedService = new GitService(basePath, gitPrefs);
cachedBasePath = basePath;
}
return cachedService;
}
/**
* Clear the cached GitService. For testing only — forces the next
* getService() call to re-read preferences and create a fresh instance.
* @internal
*/
export function _resetServiceCache() {
cachedService = null;
cachedBasePath = null;
}
/**
* Set the active milestone ID on the cached GitService.
* This enables integration branch resolution in getMainBranch().
*/
export function setActiveMilestoneId(basePath, milestoneId) {
getService(basePath).setMilestoneId(milestoneId);
}
/**
* Record the current branch as the integration branch for a milestone.
* Called once when autonomous mode starts — captures where slice branches should
* merge back to. No-op if the same branch is already recorded. Updates the
* record when the user starts from a different branch (#300). Always a no-op
* if on a SF slice branch.
*/
export function captureIntegrationBranch(basePath, milestoneId) {
// In a worktree, the base branch is implicit (worktree/<name>).
// Writing it to META.json would leave stale metadata after merge back to main.
if (detectWorktreeName(basePath)) return;
const svc = getService(basePath);
const current = svc.getCurrentBranch();
writeIntegrationBranch(basePath, milestoneId, current);
}
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
/**
* Resolve the project root from a path that may be inside a worktree.
* If the path contains a worktrees segment, returns the portion before
* `/.sf/`. Otherwise returns the input unchanged.
*
* When the worker was spawned with SF_PROJECT_ROOT set, use that directly —
* the coordinator already knows the real project root unambiguously.
*
* When `/.sf/` in the resolved path is actually the user-level `~/.sf/`
* (common when `.sf` is a symlink into `~/.sf/projects/<hash>`), the
* string-slice heuristic would return `~` — which is catastrophically wrong.
* In that case, fall back to reading the worktree's `.git` file, which
* contains a `gitdir:` pointer to the real project's `.git/worktrees/<name>`,
* giving the real project root unambiguously.
*
* Use this in commands that call `process.cwd()` to ensure they always
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath) {
// Layer 1: If the coordinator passed the real project root, use it.
if (process.env.SF_PROJECT_ROOT) {
return process.env.SF_PROJECT_ROOT;
}
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Candidate root via the string-slice heuristic
const sepChar = basePath.includes("\\") ? "\\" : "/";
const sfMarker = `${sepChar}.sf${sepChar}`;
const sfIdx = basePath.indexOf(sfMarker);
const candidate =
sfIdx !== -1 ? basePath.slice(0, sfIdx) : basePath.slice(0, seg.sfIdx);
// Layer 2: Guard against resolving to the user's home directory.
// When .sf is a symlink into ~/.sf/projects/<hash>, the resolved path
// contains /.sf/ at the user-level boundary. Slicing there yields ~ — wrong.
const sfHomePath = normalizePathForCompare(sfHome());
const candidateSfPath = normalizePathForCompare(join(candidate, ".sf"));
if (candidateSfPath === sfHomePath || candidateSfPath.startsWith(sfHomePath + "/")) {
// The candidate is the home directory (or within it in a way that .sf
// maps to the user-level SF dir). Try to recover the real project root
// from the worktree's .git file.
const realRoot = resolveProjectRootFromGitFile(basePath);
if (realRoot) return realRoot;
// If git file resolution failed, return basePath unchanged rather than ~
return basePath;
}
return candidate;
}
/**
* Recover the real project root from a worktree's .git file.
*
* Each git worktree has a `.git` file (not directory) containing:
* gitdir: /real/project/.git/worktrees/<name>
*
* Walking up from that gitdir gives us `/real/project/.git`, and its
* parent is the real project root.
*/
function resolveProjectRootFromGitFile(worktreePath) {
try {
// Walk up from the worktree path to find the .git file
let dir = worktreePath;
for (let i = 0; i < 30; i++) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
const content = readFileSync(gitPath, "utf8").trim();
if (content.startsWith("gitdir: ")) {
// gitdir points to: <real-project>/.git/worktrees/<name>
const gitDir = resolve(dir, content.slice(8));
// Walk up: .git/worktrees/<name> → .git/worktrees → .git → project root
const dotGitDir = resolve(gitDir, "..", "..");
// Verify this looks like a .git directory
if (
dotGitDir.endsWith(".git") ||
dotGitDir.endsWith(".git/") ||
dotGitDir.endsWith(".git\\")
) {
return resolve(dotGitDir, "..");
}
// Alternative: the commondir file inside the worktree gitdir
// points to the main .git directory
const commonDirPath = join(gitDir, "commondir");
if (existsSync(commonDirPath)) {
const commonDir = readFileSync(commonDirPath, "utf8").trim();
const resolvedCommonDir = resolve(gitDir, commonDir);
return resolve(resolvedCommonDir, "..");
}
}
break;
}
const parent = resolve(dir, "..");
if (parent === dir) break;
dir = parent;
}
} catch {
// Non-fatal — caller will use fallback
}
return null;
}
function normalizePathForCompare(path) {
let normalized;
try {
normalized = realpathSync(path);
} catch {
normalized = resolve(path);
}
const slashed = normalized.replaceAll("\\", "/");
const trimmed = slashed.replace(/\/+$/, "");
return trimmed || "/";
}
/**
* Get the slice branch name, namespaced by worktree when inside one.
*
* In the main tree: sf/<milestoneId>/<sliceId>
* In a worktree: sf/<worktreeName>/<milestoneId>/<sliceId>
*
* This prevents branch conflicts when multiple worktrees work on the
* same milestone/slice IDs — git doesn't allow a branch to be checked
* out in more than one worktree simultaneously.
*/
export function getSliceBranchName(milestoneId, sliceId, worktreeName) {
if (worktreeName) {
return `sf/${worktreeName}/${milestoneId}/${sliceId}`;
}
return `sf/${milestoneId}/${sliceId}`;
}
/** Re-export for backward compatibility — canonical definition in branch-patterns.ts */
export { SLICE_BRANCH_RE } from "./branch-patterns.js";
import { SLICE_BRANCH_RE } from "./branch-patterns.js";
/**
* Parse a slice branch name into its components.
* Handles both `sf/M001/S01` and `sf/myworktree/M001/S01`.
*/
export function parseSliceBranch(branchName) {
const match = branchName.match(SLICE_BRANCH_RE);
if (!match) return null;
return {
worktreeName: match[1] ?? null,
milestoneId: match[2],
sliceId: match[3],
};
}
// ─── Git-Mutation Functions (delegate to GitService) ───────────────────
/**
* Get the "main" branch for SF slice operations.
*
* In the main working tree: returns main/master (the repo's default branch).
* In a worktree: returns worktree/<name> — the worktree's own base branch.
*
* This is critical because git doesn't allow a branch to be checked out
* in more than one worktree. Slice branches merge into the worktree's base
* branch, and the worktree branch later merges into the real main via
* /worktree merge.
*/
export function getMainBranch(basePath) {
return getService(basePath).getMainBranch();
}
export function getCurrentBranch(basePath) {
return getService(basePath).getCurrentBranch();
}
/**
* Auto-commit any dirty files in the current working tree.
*
* When `taskContext` is provided, generates a meaningful conventional commit
* message from the task summary (one-liner, inferred type, key files).
* Falls back to a generic `chore()` message for non-task commits.
*
* Returns the commit message used, or null if already clean.
*/
export function autoCommitCurrentBranch(
basePath,
unitType,
unitId,
taskContext,
sessionId,
) {
return getService(basePath).autoCommit(
unitType,
unitId,
[],
taskContext,
sessionId,
);
}
// ─── Git HEAD Resolution ────────────────────────────────────────────────────
/**
* Resolve the git HEAD file path for a given directory.
* Handles both normal repos (.git is a directory) and worktrees (.git is a file
* containing a `gitdir:` pointer to the real gitdir).
*/
export function resolveGitHeadPath(dir) {
const gitPath = join(dir, ".git");
if (!existsSync(gitPath)) return null;
try {
const content = readFileSync(gitPath, "utf8").trim();
if (content.startsWith("gitdir: ")) {
const gitDir = resolve(dir, content.slice(8));
const headPath = join(gitDir, "HEAD");
return existsSync(headPath) ? headPath : null;
}
const headPath = join(dir, ".git", "HEAD");
return existsSync(headPath) ? headPath : null;
} catch {
return null;
}
}
/**
* Nudge pi's FooterDataProvider to re-read the git branch after chdir.
* Touches HEAD in both old and new cwd to fire the fs watcher.
*/
export function nudgeGitBranchCache(previousCwd) {
const now = new Date();
for (const dir of [previousCwd, process.cwd()]) {
try {
const headPath = resolveGitHeadPath(dir);
if (headPath) utimesSync(headPath, now, now);
} catch {
// Best-effort
}
}
}