/**
 * 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, VALID_BRANCH_NAME } 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-detect.js";
/**
 * 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",
	],
];
// ─── GitService ────────────────────────────────────────────────────
export class GitService {
	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, sessionId) {
		// 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 base = taskContext
			? buildTaskCommitMessage(taskContext)
			: `chore: auto-commit after ${unitType}\n\nSF-Unit: ${unitId}`;
		const message = sessionId ? `${base}\nSF-Session: ${sessionId}` : base;
		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/next/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/next/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 GitService with the current effective git preferences. */
export function createGitService(basePath) {
	const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git ?? {};
	return new GitService(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";
}
