848 lines
32 KiB
JavaScript
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";
|
|
}
|