singularity-forge/src/resources/extensions/sf/git-service.js

848 lines
32 KiB
JavaScript

/**
* SF Git Service
*
* Core git operations for SF: types, constants, and pure helpers.
* Higher-level operations (commit, staging, branching) build on these.
*
* This module centralizes the GitPreferences interface, runtime exclusion
* paths, commit type inference, and the runGit shell helper.
*/
import { execFileSync, execSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
import {
QUICK_BRANCH_RE,
SLICE_BRANCH_RE,
WORKFLOW_BRANCH_RE,
} from "./branch-patterns.js";
import { getErrorMessage } from "./error-utils.js";
import { SF_GIT_ERROR, SF_MERGE_CONFLICT, SFError } from "./errors.js";
import { normalizePlannedFileReference } from "./files.js";
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
import { SF_RUNTIME_PATTERNS } from "./git-runtime-patterns.js";
import {
_resetHasChangesCache,
nativeAddAllWithExclusions,
nativeAddPaths,
nativeBranchExists,
nativeCommit,
nativeCommitSubject,
nativeDetectMainBranch,
nativeGetCurrentBranch,
nativeHasChanges,
nativeHasStagedChanges,
nativeResetSoft,
nativeRmCached,
nativeUpdateRef,
} from "./native-git-bridge.js";
import { sfRoot } from "./paths.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { detectWorktreeName } from "./worktree.js";
/** Regex for valid git branch names (alphanumeric, hyphens, underscores, slashes). */
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-/.]+$/;
/**
* Build a meaningful conventional commit message from task execution context.
* Format: `{type}: {description}` (clean conventional commit — no SF IDs in subject).
*
* SF metadata is placed in a `SF-Task:` git trailer at the end of the body,
* following the same convention as `Signed-off-by:` or `Co-Authored-By:`.
*
* The description is the task summary one-liner if available (it describes
* what was actually built), falling back to the task title (what was planned).
*/
export function buildTaskCommitMessage(ctx) {
const description = ctx.oneLiner || ctx.taskTitle;
const type = inferCommitType(ctx.taskTitle, ctx.oneLiner);
// Truncate description to ~72 chars for subject line (full budget without scope)
const maxDescLen = 70 - type.length;
const truncated =
description.length > maxDescLen
? description.slice(0, maxDescLen - 1).trimEnd() + "…"
: description;
const subject = `${type}: ${truncated}`;
// Build body with key files if available
const bodyParts = [];
const keyFiles = ctx.keyFiles?.filter(
(file) => normalizeExplicitStagePath(file) !== null,
);
if (keyFiles && keyFiles.length > 0) {
const fileLines = keyFiles
.slice(0, 8) // cap at 8 files to keep commit concise
.map((f) => `- ${f}`)
.join("\n");
bodyParts.push(fileLines);
}
// Trailers: SF-Task first, then Resolves
bodyParts.push(`SF-Task: ${ctx.taskId}`);
if (ctx.issueNumber) {
bodyParts.push(`Resolves #${ctx.issueNumber}`);
}
return `${subject}\n\n${bodyParts.join("\n\n")}`;
}
/**
* Thrown when a slice merge hits code conflicts in non-.sf files.
* The working tree is left in a conflicted state (no reset) so the
* caller can dispatch a fix-merge session to resolve it.
*/
export class MergeConflictError extends SFError {
conflictedFiles;
strategy;
branch;
mainBranch;
constructor(conflictedFiles, strategy, branch, mainBranch) {
super(
SF_MERGE_CONFLICT,
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" ` +
`failed with conflicts in ${conflictedFiles.length} non-.sf file(s): ${conflictedFiles.join(", ")}`,
);
this.name = "MergeConflictError";
this.conflictedFiles = conflictedFiles;
this.strategy = strategy;
this.branch = branch;
this.mainBranch = mainBranch;
}
}
// ─── Constants ─────────────────────────────────────────────────────────────
/**
* SF runtime paths that should be excluded from smart staging.
* These are transient/generated artifacts that should never be committed.
*
* Imported from gitignore.ts (canonical source of truth).
*/
export const RUNTIME_EXCLUSION_PATHS = SF_RUNTIME_PATTERNS;
function isPathExcluded(path, exclusions) {
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
return exclusions.some((rawExclusion) => {
const exclusion = rawExclusion.replace(/\\/g, "/").replace(/^\.\//, "");
if (!exclusion) return false;
if (exclusion.includes("*")) {
const prefix = exclusion.slice(0, exclusion.indexOf("*"));
return normalized.startsWith(prefix);
}
if (exclusion.endsWith("/")) return normalized.startsWith(exclusion);
return normalized === exclusion || normalized.startsWith(`${exclusion}/`);
});
}
function normalizeExplicitStagePath(path) {
const normalized = normalize(
normalizePlannedFileReference(path).replace(/\\/g, "/"),
)
.replace(/\\/g, "/")
.replace(/^\.\//, "");
const lower = normalized.toLowerCase();
if (
!normalized ||
normalized === "." ||
lower === "(none)" ||
lower === "none." ||
lower === "n/a" ||
lower === "-" ||
normalized.includes("\0") ||
isAbsolute(normalized) ||
/^[A-Za-z]:\//.test(normalized) ||
normalized === ".." ||
normalized.startsWith("../")
) {
return null;
}
return normalized;
}
// ─── Integration Branch Metadata ───────────────────────────────────────────
/**
* Path to the milestone metadata file that stores the integration branch.
* Format: .sf/milestones/<MID>/<MID>-META.json
*/
function milestoneMetaPath(basePath, milestoneId) {
return join(
sfRoot(basePath),
"milestones",
milestoneId,
`${milestoneId}-META.json`,
);
}
/**
* Read the integration branch recorded for a milestone.
* Returns null if no metadata file exists or the branch isn't set.
*/
export function readIntegrationBranch(basePath, milestoneId) {
try {
const metaFile = milestoneMetaPath(basePath, milestoneId);
if (!existsSync(metaFile)) return null;
const data = JSON.parse(readFileSync(metaFile, "utf-8"));
const branch = data?.integrationBranch;
if (
typeof branch === "string" &&
branch.trim() !== "" &&
VALID_BRANCH_NAME.test(branch)
) {
return branch;
}
return null;
} catch {
return null;
}
}
/**
* Persist the integration branch for a milestone.
*
* Called when autonomous mode starts on a milestone. Records the branch the user
* was on at that point, so the milestone worktree merges back to the correct
* branch. Idempotent when the branch matches; updates the record when the
* user starts from a different branch.
*
* The file is committed immediately so the metadata is persisted in git.
*/
/** Re-export for backward compatibility — canonical definitions in branch-patterns.ts */
export { QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js";
export function writeIntegrationBranch(basePath, milestoneId, branch) {
// Don't record slice branches as the integration target
if (SLICE_BRANCH_RE.test(branch)) return;
// Don't record quick-task branches — they are ephemeral and merge back
// to their origin branch on completion. Recording one as the integration
// target causes milestone merges to land on the wrong branch (#1293).
if (QUICK_BRANCH_RE.test(branch)) return;
// Don't record workflow-template branches (hotfix, bugfix, spike, etc.) —
// same root cause as quick-task branches (#2498). All templates create
// sf/<templateId>/<slug> branches that are ephemeral.
if (WORKFLOW_BRANCH_RE.test(branch)) return;
// Validate
if (!VALID_BRANCH_NAME.test(branch)) return;
// Skip if already recorded with the same branch (idempotent across restarts).
// If recorded with a different branch, update it — the user started autonomous mode
// from a new branch and expects slices to merge back there (#300).
const existingBranch = readIntegrationBranch(basePath, milestoneId);
if (existingBranch === branch) return;
const metaFile = milestoneMetaPath(basePath, milestoneId);
mkdirSync(join(sfRoot(basePath), "milestones", milestoneId), {
recursive: true,
});
// Merge with existing metadata if present
let existing = {};
try {
if (existsSync(metaFile)) {
existing = JSON.parse(readFileSync(metaFile, "utf-8"));
}
} catch {
/* corrupt file — overwrite */
}
existing.integrationBranch = branch;
writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
// .sf/ is managed externally (symlinked) — metadata is not committed to git.
}
/**
* Resolve a milestone's recorded integration branch into an actionable status.
*
* This helper is intentionally scoped to milestones that already have recorded
* metadata. If no integration branch is recorded, it returns `missing` with no
* effective branch so callers can continue with their existing non-milestone
* fallback logic (for example worktree/current-branch detection in getMainBranch).
*/
export function resolveMilestoneIntegrationBranch(
basePath,
milestoneId,
prefs = {},
) {
const recordedBranch = readIntegrationBranch(basePath, milestoneId);
if (!recordedBranch) {
return {
recordedBranch: null,
effectiveBranch: null,
status: "missing",
reason: `Milestone ${milestoneId} has no recorded integration branch metadata.`,
};
}
if (nativeBranchExists(basePath, recordedBranch)) {
return {
recordedBranch,
effectiveBranch: recordedBranch,
status: "recorded",
reason: `Using recorded integration branch "${recordedBranch}" for milestone ${milestoneId}.`,
};
}
const configuredBranch =
prefs.main_branch && VALID_BRANCH_NAME.test(prefs.main_branch)
? prefs.main_branch
: null;
if (configuredBranch) {
if (nativeBranchExists(basePath, configuredBranch)) {
return {
recordedBranch,
effectiveBranch: configuredBranch,
status: "fallback",
reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using configured git.main_branch "${configuredBranch}" instead.`,
};
}
return {
recordedBranch,
effectiveBranch: null,
status: "missing",
reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and configured git.main_branch "${configuredBranch}" is unavailable.`,
};
}
try {
const detectedBranch = nativeDetectMainBranch(basePath);
if (
detectedBranch &&
VALID_BRANCH_NAME.test(detectedBranch) &&
nativeBranchExists(basePath, detectedBranch)
) {
return {
recordedBranch,
effectiveBranch: detectedBranch,
status: "fallback",
reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using detected fallback branch "${detectedBranch}" instead.`,
};
}
} catch {
// Fall through to the explicit missing result below.
}
return {
recordedBranch,
effectiveBranch: null,
status: "missing",
reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and no safe fallback branch could be determined.`,
};
}
// ─── Git Helper ────────────────────────────────────────────────────────────
/**
* Strip git-svn noise from error messages.
* Some systems (notably Arch Linux) have a buggy git-svn Perl module that
* emits warnings on every git invocation, confusing users. See #404.
*/
function filterGitSvnNoise(message) {
return message
.replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "")
.replace(/Unable to determine upstream SVN information from .*\n?/g, "")
.replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "")
.trim();
}
/**
* Run a git command in the given directory.
* Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set.
* When `input` is provided, it is piped to stdin.
*/
export function runGit(basePath, args, options = {}) {
try {
return execFileSync("git", args, {
cwd: basePath,
stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
...(options.input != null ? { input: options.input } : {}),
}).trim();
} catch (error) {
if (options.allowFailure) return "";
const message = getErrorMessage(error);
throw new SFError(
SF_GIT_ERROR,
`git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`,
);
}
}
// ─── Commit Type Inference ─────────────────────────────────────────────────
/**
* Keyword-to-commit-type mapping. Order matters — first match wins.
* Each entry: [keywords[], commitType]
*/
const COMMIT_TYPE_RULES = [
[
["fix", "fixed", "fixes", "bug", "patch", "hotfix", "repair", "correct"],
"fix",
],
[["refactor", "restructure", "reorganize"], "refactor"],
[["doc", "docs", "documentation", "readme", "changelog"], "docs"],
[["test", "tests", "testing", "spec", "coverage"], "test"],
[["perf", "performance", "optimize", "speed", "cache"], "perf"],
[
[
"chore",
"cleanup",
"clean up",
"dependencies",
"deps",
"bump",
"config",
"ci",
"archive",
"remove",
"delete",
],
"chore",
],
];
// ─── GitServiceImpl ────────────────────────────────────────────────────
export class GitServiceImpl {
basePath;
prefs;
/** Active milestone ID — used to resolve the integration branch. */
_milestoneId = null;
constructor(basePath, prefs = {}) {
this.basePath = basePath;
this.prefs = prefs;
}
/**
* Set the active milestone ID for integration branch resolution.
* When set, getMainBranch() will check the milestone's metadata file
* for a recorded integration branch before falling back to repo defaults.
*/
setMilestoneId(milestoneId) {
this._milestoneId = milestoneId;
}
/**
* Smart staging: `git add -A` excluding SF runtime paths via pathspec.
* Falls back to plain `git add -A` if the exclusion pathspec fails.
* @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
*/
smartStage(extraExclusions = [], explicitIncludePaths = []) {
// One-time cleanup: if runtime files are already tracked in the index
// (from older versions where the fallback bug staged them), untrack them
// in a dedicated commit. This must happen as a separate commit because
// the git reset HEAD step below would otherwise undo the rm --cached.
//
// SAFETY: Only untrack known runtime/generated paths (activity/, runtime/,
// milestones/, auto.lock, etc.) — NOT all of .sf/. Human-authored .sf
// guidance can remain tracked, while generated milestone workspaces are
// promoted to docs before becoming durable project artifacts.
if (!this._runtimeFilesCleanedUp) {
let cleaned = false;
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
const removed = nativeRmCached(this.basePath, [exclusion]);
if (removed.length > 0) cleaned = true;
}
if (cleaned) {
nativeCommit(
this.basePath,
"chore: untrack .sf/ runtime files from git index",
{ allowEmpty: false },
);
}
this._runtimeFilesCleanedUp = true;
}
// Stage everything using pathspec exclusions so excluded paths are never
// hashed by git. The old approach of `git add -A` followed by unstaging
// hangs indefinitely on repos with large untracked artifact trees (#1605).
//
// Exclude runtime/generated paths from staging — not the entire .sf/
// directory. This keeps deliberate .sf guidance trackable while preventing
// generated milestone workspaces from becoming peer source-of-truth files.
//
// If .sf/ IS in .gitignore (the default for external state projects),
// git add -A already skips it and the exclusions are harmless no-ops.
const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
// ── Parallel worker milestone scope (#1991) ──
// When SF_MILESTONE_LOCK is set, this process is a parallel worker that
// must only commit files belonging to its own milestone. Exclude all other
// milestone directories from staging to prevent cross-milestone pollution
// (e.g., an M033 worker fabricating M032 artifacts in the same commit).
const milestoneLock = process.env.SF_MILESTONE_LOCK;
if (milestoneLock) {
const msDir = join(sfRoot(this.basePath), "milestones");
if (existsSync(msDir)) {
try {
const entries = readdirSync(msDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== milestoneLock) {
allExclusions.push(`.sf/milestones/${entry.name}/`);
}
}
} catch {
// Best-effort — if we can't read the milestones dir, proceed without scoping
}
}
}
nativeAddAllWithExclusions(this.basePath, allExclusions);
this.stageExplicitIncludePaths(explicitIncludePaths, allExclusions);
}
stageExplicitIncludePaths(paths, exclusions) {
const seen = new Set();
const safePaths = paths
.map(normalizeExplicitStagePath)
.filter((path) => path !== null)
.filter((path) => !isPathExcluded(path, exclusions))
// Second barrier: drop any path whose first segment is `.sf`. This
// prevents explicit `.sf/...` paths from reaching nativeAddPaths even
// when `.sf` is a real directory (not just a symlink).
.filter((path) => path.replace(/\\/g, "/").split("/")[0] !== ".sf")
.filter((path) => {
if (seen.has(path)) return false;
seen.add(path);
return true;
});
if (safePaths.length === 0) return;
nativeAddPaths(this.basePath, safePaths);
}
/** Tracks whether runtime file cleanup has run this session. */
_runtimeFilesCleanedUp = false;
/**
* Stage files (smart staging) and commit.
* Returns the commit message string on success, or null if nothing to commit.
* Uses `git commit -F -` with stdin pipe for safe multi-line message handling.
*/
commit(opts) {
this.smartStage();
// Check if anything was actually staged
if (!nativeHasStagedChanges(this.basePath) && !opts.allowEmpty) return null;
nativeCommit(this.basePath, opts.message, {
allowEmpty: opts.allowEmpty ?? false,
});
return opts.message;
}
/**
* Auto-commit dirty working tree.
*
* When `taskContext` is provided, generates a meaningful conventional commit
* message from the task execution results (one-liner, title, inferred type).
* Falls back to a generic `chore()` message when no context is available
* (e.g. pre-switch commits, stop commits, state rebuild commits).
*
* Returns the commit message on success, or null if nothing to commit.
* @param extraExclusions Additional paths to exclude from staging (e.g. [".sf/"] for pre-switch commits).
*/
autoCommit(unitType, unitId, extraExclusions = [], taskContext) {
// Quick check: is there anything dirty at all?
// Native path uses libgit2 (single syscall), fallback spawns git.
if (!nativeHasChanges(this.basePath)) return null;
this.smartStage(extraExclusions, taskContext?.keyFiles ?? []);
// After smart staging, check if anything was actually staged
// (all changes might have been runtime files that got excluded)
if (!nativeHasStagedChanges(this.basePath)) return null;
const message = taskContext
? buildTaskCommitMessage(taskContext)
: `chore: auto-commit after ${unitType}\n\nSF-Unit: ${unitId}`;
nativeCommit(this.basePath, message, { allowEmpty: false });
// Absorb any preceding sf snapshot commits into this real commit.
// Walk backwards from HEAD~1 counting consecutive snapshot subjects,
// then soft-reset to before them and re-commit with the same message.
this.absorbSnapshotCommits(message);
return message;
}
/**
* Squash consecutive `sf snapshot:` commits that sit immediately below
* HEAD into the current HEAD commit. This keeps the git history clean
* after automated snapshot commits are superseded by real work.
*
* Guards:
* - Opt-in via `absorb_snapshot_commits` preference (default: true).
* - Refuses to rewrite commits that have been pushed to the remote
* tracking branch (checks merge-base ancestry).
* - Saves HEAD SHA before reset; restores it if the re-commit fails.
*
* Does nothing if there are no snapshot commits to absorb.
*/
absorbSnapshotCommits(headMessage) {
try {
// Opt-in guard — users can disable to keep snapshot commits for forensics
if (this.prefs.absorb_snapshot_commits === false) return;
const SF_SNAPSHOT_PREFIX = "sf snapshot:";
let count = 0;
// Walk back from HEAD~1 counting consecutive snapshot commits (cap at 10)
for (let i = 1; i <= 10; i++) {
const subject = nativeCommitSubject(this.basePath, `HEAD~${i}`);
if (!subject.startsWith(SF_SNAPSHOT_PREFIX)) break;
count = i;
}
if (count === 0) return;
// Guard: don't rewrite history that has been pushed to the remote.
// Check whether the newest snapshot commit (HEAD~1) is already
// reachable from the remote tracking branch. If it is, the snapshots
// have been pushed and must not be squashed via local history rewrite.
// (Checking resetTarget instead would false-positive when the remote
// is at the pre-snapshot base but the snapshots themselves are local.)
const resetTarget = `HEAD~${count + 1}`;
try {
const branch = nativeGetCurrentBranch(this.basePath);
if (branch) {
const remoteBranch = `origin/${branch}`;
// merge-base --is-ancestor exits 0 if HEAD~1 is ancestor of remote
execFileSync(
"git",
["merge-base", "--is-ancestor", "HEAD~1", remoteBranch],
{
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
},
);
// If we get here, newest snapshot IS reachable from remote — already pushed
return;
}
} catch {
// Not an ancestor or remote doesn't exist — safe to proceed
}
// Save HEAD SHA so we can restore if the re-commit fails
const savedHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
nativeResetSoft(this.basePath, resetTarget);
// Re-run smartStage so the same RUNTIME_EXCLUSION_PATHS apply.
// Snapshot commits used nativeAddTracked (git add --ignore-removal) which stages
// ALL tracked modifications including .sf/ state files. Without
// re-staging, those .sf/ changes leak into the absorbed commit.
this.smartStage();
try {
nativeCommit(this.basePath, headMessage, { allowEmpty: false });
} catch {
// Re-commit failed — restore original HEAD to avoid leaving the
// repo in a partially-reset state with no commit
nativeResetSoft(this.basePath, savedHead);
}
} catch {
// Non-fatal — if squash fails, the commits remain unsquashed
}
}
// ─── Branch Queries ────────────────────────────────────────────────────
/**
* Get the integration branch for this repo — the branch that slice
* branches are created from and merged back into.
*
* This is often `main` or `master`, but not necessarily. When a user
* starts SF on a feature branch like `f-123-new-thing`, that branch
* is recorded as the integration target, and all slice branches merge
* back into it — not the repo's default branch. The name "main branch"
* in variable names is historical; think of it as "integration branch".
*
* Resolution order:
* 1. Explicit `main_branch` preference (user override, highest priority)
* 2. Milestone integration branch from metadata file (recorded at milestone start)
* 3. Worktree base branch (worktree/<name>)
* 4. origin/HEAD symbolic-ref → main/master fallback → current branch
*/
getMainBranch() {
// Explicit preference takes priority (double-check validity as defense-in-depth)
if (
this.prefs.main_branch &&
VALID_BRANCH_NAME.test(this.prefs.main_branch)
) {
return this.prefs.main_branch;
}
// Check milestone integration branch — recorded when autonomous mode starts
if (this._milestoneId) {
const resolved = resolveMilestoneIntegrationBranch(
this.basePath,
this._milestoneId,
);
if (resolved.effectiveBranch) {
return resolved.effectiveBranch;
}
}
const wtName = detectWorktreeName(this.basePath);
if (wtName) {
// Autonomous mode worktrees use milestone/<MID> branches (wtName = milestone ID)
const _milestoneBranch = `milestone/${wtName}`;
const currentBranch = nativeGetCurrentBranch(this.basePath);
// If we're on a milestone/<MID> branch, use it (autonomous mode case)
if (currentBranch.startsWith("milestone/")) {
return currentBranch;
}
// Otherwise check for manual worktree branch (worktree/<name>)
const wtBranch = `worktree/${wtName}`;
if (nativeBranchExists(this.basePath, wtBranch)) return wtBranch;
return currentBranch;
}
// Repo-level default detection: origin/HEAD → main → master → current branch.
// Native path uses libgit2 (single call), fallback spawns multiple git processes.
return nativeDetectMainBranch(this.basePath);
}
/** Get the current branch name. Native libgit2 when available, execSync fallback. */
getCurrentBranch() {
return nativeGetCurrentBranch(this.basePath);
}
/**
* Create a snapshot ref for the given label (typically a slice branch name).
* Enabled by default; opt out with prefs.snapshots === false.
* Ref path: refs/sf/snapshots/<label>/<timestamp>
* The ref points at HEAD, capturing the current commit before destructive operations.
*/
createSnapshot(label) {
if (this.prefs.snapshots === false) return;
const now = new Date();
const ts =
now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, "0") +
String(now.getDate()).padStart(2, "0") +
"-" +
String(now.getHours()).padStart(2, "0") +
String(now.getMinutes()).padStart(2, "0") +
String(now.getSeconds()).padStart(2, "0");
const refPath = `refs/sf/snapshots/${label}/${ts}`;
nativeUpdateRef(this.basePath, refPath, "HEAD");
}
/**
* Stage files without committing. Returns true if anything was staged.
*
* Used by Fix 1 deferral: call this in postUnitPreVerification so changes
* are captured before verification, then call commitStaged() in
* postUnitPostVerification once verification has passed.
*
* @param extraExclusions Additional paths to exclude from staging.
* @param explicitIncludePaths Task-declared files to stage even when the
* symlinked .sf fallback must avoid broad untracked traversal.
*/
stageOnly(extraExclusions = [], explicitIncludePaths = []) {
if (!nativeHasChanges(this.basePath)) return false;
this.smartStage(extraExclusions, explicitIncludePaths);
return nativeHasStagedChanges(this.basePath);
}
/**
* Commit already-staged files (no re-staging). Returns true if committed.
*
* Companion to stageOnly() for the Fix 1 deferred-commit pattern.
* Only calls nativeCommit when there are actually staged changes.
*
* @param message The commit message to use.
*/
commitStaged(message) {
if (!nativeHasStagedChanges(this.basePath)) return false;
nativeCommit(this.basePath, message, { allowEmpty: false });
return true;
}
/**
* Run pre-merge verification check. Auto-detects test runner from project
* files, or uses custom command from prefs.pre_merge_check.
* Gated on prefs.pre_merge_check (false = skip, string = custom command).
* Stub: to be implemented in T03.
*/
runPreMergeCheck() {
if (this.prefs.pre_merge_check === false) {
return { passed: true, skipped: true };
}
// Determine command: explicit string or auto-detect from package.json
let command;
if (typeof this.prefs.pre_merge_check === "string") {
command = this.prefs.pre_merge_check;
} else {
// Auto-detect: look for package.json with a test script
try {
const pkg = readFileSync(join(this.basePath, "package.json"), "utf-8");
const parsed = JSON.parse(pkg);
if (parsed.scripts?.test) {
command = "npm test";
} else {
return { passed: true, skipped: true };
}
} catch {
return { passed: true, skipped: true };
}
}
try {
execSync(command, {
cwd: this.basePath,
stdio: "pipe",
encoding: "utf-8",
});
return { passed: true, skipped: false, command };
} catch (err) {
const msg = getErrorMessage(err);
return { passed: false, skipped: false, command, error: msg };
}
}
}
// ─── Draft PR Creation ─────────────────────────────────────────────────────
/**
* Create a draft pull request for a completed milestone using `gh pr create`.
* Returns the PR URL on success, or null on failure.
* Non-fatal: callers should treat failure as best-effort.
*/
export function createDraftPR(basePath, _milestoneId, title, body, opts) {
try {
const args = ["pr", "create", "--draft", "--title", title, "--body", body];
if (opts?.head) args.push("--head", opts.head);
if (opts?.base) args.push("--base", opts.base);
const result = execFileSync("gh", args, {
cwd: basePath,
encoding: "utf8",
timeout: 30000,
env: GIT_NO_PROMPT_ENV,
});
return result.trim();
} catch {
return null;
}
}
// ─── Factory ───────────────────────────────────────────────────────────────
/** Create a GitServiceImpl with the current effective git preferences. */
export function createGitService(basePath) {
const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {};
return new GitServiceImpl(basePath, gitPrefs);
}
function buildTurnSnapshotLabel(unitType, unitId) {
const raw = `${unitType}/${unitId}`.trim();
if (!raw) return "turn";
return (
raw
.replace(/[^a-zA-Z0-9._/-]/g, "-")
.replace(/\/{2,}/g, "/")
.replace(/-{2,}/g, "-")
.replace(/^[-/]+|[-/]+$/g, "") || "turn"
);
}
export function runTurnGitAction(args) {
try {
// Force fresh working-tree status per turn; nativeHasChanges caches briefly.
_resetHasChangesCache();
if (args.action === "status-only") {
return {
action: args.action,
status: "ok",
dirty: nativeHasChanges(args.basePath),
};
}
const git = createGitService(args.basePath);
if (args.action === "snapshot") {
const label = buildTurnSnapshotLabel(args.unitType, args.unitId);
git.createSnapshot(label);
return {
action: args.action,
status: "ok",
snapshotLabel: label,
dirty: nativeHasChanges(args.basePath),
};
}
const commitMessage =
git.autoCommit(args.unitType, args.unitId, [], args.taskContext) ??
undefined;
return {
action: args.action,
status: "ok",
commitMessage,
dirty: nativeHasChanges(args.basePath),
};
} catch (err) {
return {
action: args.action,
status: "failed",
error: getErrorMessage(err),
};
}
}
// ─── Commit Type Inference ─────────────────────────────────────────────────
/**
* Infer a conventional commit type from a title (and optional one-liner).
* Uses case-insensitive word-boundary matching against known keywords.
* Returns "feat" when no keywords match.
*
* Used for both slice squash-merge titles and task commit messages.
*/
export function inferCommitType(title, oneLiner) {
const lower = `${title} ${oneLiner || ""}`.toLowerCase();
for (const [keywords, commitType] of COMMIT_TYPE_RULES) {
for (const keyword of keywords) {
// "clean up" is multi-word — use indexOf for it
if (keyword.includes(" ")) {
if (lower.includes(keyword)) return commitType;
} else {
// Word boundary match: keyword must not be surrounded by word chars
const re = new RegExp(`\\b${keyword}\\b`, "i");
if (re.test(lower)) return commitType;
}
}
}
return "feat";
}