- 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>
294 lines
11 KiB
JavaScript
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
|
|
}
|
|
}
|
|
}
|