VcsOperations.java

package network.ike.plugin.ws.vcs;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.Log;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * Git and VCS state operations for the IKE VCS Bridge.
 *
 * <p>All git commands use {@link ProcessBuilder}. Commands that modify
 * state (commit, push, branch creation) set {@code IKE_VCS_CONTEXT}
 * in the subprocess environment so that the pre-commit and pre-push
 * hooks allow the operation through.
 */
public class VcsOperations {

    private static final String IKE_VCS_CONTEXT = "IKE_VCS_CONTEXT";
    private static final String CONTEXT_VALUE = "ike-maven-plugin";

    private VcsOperations() {}

    // ── Git queries ──────────────────────────────────────────────

    /**
     * Get the 8-character short SHA of HEAD.
     *
     * @param dir the repository root directory
     * @return the short SHA string
     * @throws MojoException if the git command fails
     */
    public static String headSha(File dir) throws MojoException {
        return capture(dir, "git", "rev-parse", "--short=8", "HEAD");
    }

    /**
     * Get the current branch name.
     *
     * @param dir the repository root directory
     * @return the current branch name
     * @throws MojoException if the git command fails
     */
    public static String currentBranch(File dir) throws MojoException {
        return capture(dir, "git", "branch", "--show-current");
    }

    /**
     * Get the 8-character short SHA of a remote branch, or empty if unreachable.
     *
     * @param dir    the repository root directory
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch name to query
     * @return the short SHA, or empty if the remote branch is unreachable
     * @throws MojoException if the git command fails
     */
    public static Optional<String> remoteSha(File dir, String remote, String branch)
            throws MojoException {
        try {
            String output = capture(dir, "git", "ls-remote", remote, branch);
            if (output.isEmpty()) {
                return Optional.empty();
            }
            // ls-remote output: <full-sha>\trefs/heads/<branch>
            String fullSha = output.split("\\s+")[0];
            return Optional.of(fullSha.substring(0, 8));
        } catch (MojoException e) {
            return Optional.empty();
        }
    }

    /**
     * Check whether the working tree is clean (no staged or unstaged changes).
     *
     * @param dir the repository root directory
     * @return true if the working tree has no changes
     */
    public static boolean isClean(File dir) {
        try {
            String status = capture(dir, "git", "status", "--porcelain");
            return status.isEmpty();
        } catch (MojoException e) {
            return false;
        }
    }

    /**
     * Check whether there are staged changes ready to commit.
     *
     * @param dir the repository root directory
     * @return true if the index has staged changes
     */
    public static boolean hasStagedChanges(File dir) {
        try {
            String diff = capture(dir, "git", "diff", "--cached", "--name-only");
            return !diff.isEmpty();
        } catch (MojoException e) {
            return false;
        }
    }

    /**
     * Check whether there are modified but unstaged changes in the working tree.
     *
     * @param dir the repository root directory
     * @return true if there are unstaged modifications
     */
    public static boolean hasUnstagedChanges(File dir) {
        try {
            String diff = capture(dir, "git", "diff", "--name-only");
            return !diff.isEmpty();
        } catch (MojoException e) {
            return false;
        }
    }

    /**
     * List files with unstaged modifications in the working tree.
     * Returns a comma-separated summary suitable for log messages.
     *
     * @param dir the repository root directory
     * @return comma-separated file names, or empty string if clean
     */
    public static String unstagedFiles(File dir) {
        try {
            String diff = capture(dir, "git", "diff", "--name-only");
            if (diff.isEmpty()) return "";
            return String.join(", ", diff.split("\n"));
        } catch (MojoException e) {
            return "";
        }
    }

    /**
     * List files with any uncommitted changes (staged, unstaged, or untracked).
     * Returns the raw porcelain output suitable for detailed error messages.
     *
     * @param dir the repository root directory
     * @return porcelain status output, or empty string if clean
     */
    public static String uncommittedStatus(File dir) {
        try {
            return capture(dir, "git", "status", "--porcelain");
        } catch (MojoException e) {
            return "";
        }
    }

    /**
     * Count tracked files with modifications (staged or unstaged), excluding
     * untracked files. Use {@link #untrackedFiles(File)} for the new-file list.
     *
     * @param dir the repository root directory
     * @return count of modified tracked files; zero if clean or on error
     */
    public static int modifiedTrackedCount(File dir) {
        try {
            String porcelain = capture(dir, "git", "status", "--porcelain");
            if (porcelain.isEmpty()) return 0;
            int count = 0;
            for (String line : porcelain.split("\n")) {
                if (line.length() < 2) continue;
                // ?? = untracked, !! = ignored; everything else is tracked
                if (!line.startsWith("??") && !line.startsWith("!!")) count++;
            }
            return count;
        } catch (MojoException e) {
            return 0;
        }
    }

    /**
     * List untracked, non-ignored files in the working tree.
     *
     * @param dir the repository root directory
     * @return list of untracked file paths; empty if clean or on error
     */
    public static List<String> untrackedFiles(File dir) {
        try {
            String output = capture(dir, "git", "ls-files",
                    "--others", "--exclude-standard");
            if (output.isEmpty()) return List.of();
            return List.of(output.split("\n"));
        } catch (MojoException e) {
            return List.of();
        }
    }

    /**
     * List files with unresolved merge conflicts.
     *
     * @param dir the repository root directory
     * @return list of conflicting file paths, empty if none
     */
    public static List<String> conflictingFiles(File dir) {
        try {
            String output = capture(dir, "git", "diff", "--name-only", "--diff-filter=U");
            if (output.isEmpty()) return List.of();
            return List.of(output.split("\n"));
        } catch (MojoException e) {
            return List.of();
        }
    }

    /**
     * Predict merge conflicts without touching the index or working tree.
     *
     * <p>Uses {@code git merge-tree --write-tree} (git 2.38+) to perform
     * a trial merge in memory. Returns the list of conflicting file paths,
     * or an empty list if the merge would be clean.
     *
     * <p>Falls back gracefully on older git versions — returns an empty
     * list (conflict prediction unavailable).
     *
     * @param dir    the repository root directory
     * @param branch the branch to merge into (e.g., current feature branch)
     * @param other  the branch to merge from (e.g., "main")
     * @return list of file paths that would conflict, empty if clean or unknown
     */
    public static List<String> predictConflicts(File dir, String branch, String other) {
        try {
            // git merge-tree --write-tree exits 0 if clean, 1 if conflicts
            // With --name-only, conflicting file names appear after a blank line
            ProcessBuilder pb = new ProcessBuilder(
                    "git", "merge-tree", "--write-tree", "--name-only",
                    branch, other)
                    .directory(dir)
                    .redirectErrorStream(false);
            Process proc = pb.start();

            String stdout = new String(
                    proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
            String stderr = new String(
                    proc.getErrorStream().readAllBytes(), StandardCharsets.UTF_8).trim();
            int exit = proc.waitFor();

            if (exit == 0) {
                return List.of(); // clean merge
            }

            if (exit == 1 && !stdout.isEmpty()) {
                // Output format: tree SHA on first line, then blank line,
                // then conflicting file names (one per line)
                String[] sections = stdout.split("\n\n", 2);
                if (sections.length == 2 && !sections[1].isBlank()) {
                    return List.of(sections[1].trim().split("\n"));
                }
            }

            // Unexpected exit or format — can't predict
            return List.of();
        } catch (Exception e) {
            // git merge-tree not available or failed — can't predict
            return List.of();
        }
    }

    /**
     *
     * @param dir  the repository root directory
     * @param base the starting ref (exclusive)
     * @param head the ending ref (inclusive)
     * @return list of one-line commit summaries between the two refs
     * @throws MojoException if the git command fails
     */
    public static List<String> commitLog(File dir, String base, String head)
            throws MojoException {
        String output = capture(dir, "git", "log",
                base + ".." + head, "--oneline", "--no-decorate");
        if (output.isEmpty()) return List.of();
        return List.of(output.split("\n"));
    }

    // ── Git operations ───────────────────────────────────────────

    /**
     * Fetch from all remotes.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @throws MojoException if the git command fails
     */
    public static void fetch(File dir, Log log) throws MojoException {
        run(dir, log, null, "git", "fetch", "--all", "--quiet");
    }

    /**
     * Soft reset (no --hard) — updates HEAD and index, leaves working tree.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @param ref the ref to reset to (e.g., "origin/main")
     * @throws MojoException if the git command fails
     */
    public static void resetSoft(File dir, Log log, String ref)
            throws MojoException {
        run(dir, log, null, "git", "reset", ref, "--quiet");
    }

    /**
     * Hard reset — updates HEAD, index, and working tree to match
     * {@code ref}, discarding uncommitted changes. Also clears any
     * in-progress merge state ({@code .git/SQUASH_MSG},
     * {@code .git/MERGE_MSG}), which is useful after a {@code git merge
     * --squash} whose squashed diff turned out to be empty — see
     * issue #162.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @param ref the ref to reset to (e.g., {@code "HEAD"})
     * @throws MojoException if the git command fails
     */
    public static void resetHard(File dir, Log log, String ref)
            throws MojoException {
        run(dir, log, null, "git", "reset", "--hard", ref, "--quiet");
    }

    /**
     * Check whether one commit is an ancestor of (or equal to) another.
     *
     * <p>Uses {@code git merge-base --is-ancestor}: exit 0 means yes,
     * exit 1 means no, any other exit is an error (e.g., unknown ref).
     *
     * @param dir           the repository root directory
     * @param maybeAncestor candidate ancestor commit (ref or sha)
     * @param descendant    candidate descendant commit (ref or sha)
     * @return {@code true} iff {@code maybeAncestor} is reachable from
     *         {@code descendant} via parent edges (or is equal)
     * @throws MojoException if either ref is unknown or the git command fails
     */
    public static boolean isAncestor(File dir, String maybeAncestor, String descendant)
            throws MojoException {
        try {
            Process proc = new ProcessBuilder("git", "merge-base",
                    "--is-ancestor", maybeAncestor, descendant)
                    .directory(dir)
                    .redirectErrorStream(true)
                    .start();
            String output = new String(proc.getInputStream().readAllBytes(),
                    StandardCharsets.UTF_8);
            int exit = proc.waitFor();
            if (exit == 0) return true;
            if (exit == 1) return false;
            throw new MojoException(
                    "git merge-base --is-ancestor " + maybeAncestor + " "
                            + descendant + " failed (exit " + exit + "): " + output);
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to check ancestry of " + maybeAncestor + " / "
                            + descendant + ": " + e.getMessage(), e);
        }
    }

    /**
     * Checkout an existing branch.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param branch the branch to check out
     * @throws MojoException if the git command fails
     */
    public static void checkout(File dir, Log log, String branch)
            throws MojoException {
        run(dir, log, null, "git", "checkout", branch);
    }

    /**
     * Create and checkout a new branch.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param branch the new branch name to create
     * @throws MojoException if the git command fails
     */
    public static void checkoutNew(File dir, Log log, String branch)
            throws MojoException {
        run(dir, log, null, "git", "checkout", "-b", branch);
    }

    /**
     * Commit the currently-staged changes with the given message via
     * {@code git commit -F -} (message piped on stdin, no shell quoting
     * issues, no argument-length limits). Does not stage — callers
     * stage first via {@link #addAll} or per-path {@code git add}.
     * Sets {@code IKE_VCS_CONTEXT} to bypass the pre-commit hook.
     *
     * @param dir     the repository root directory
     * @param log     Maven logger
     * @param message the commit message; must not be {@code null} or blank
     * @throws MojoException if the git command fails
     */
    public static void commit(File dir, Log log, String message)
            throws MojoException {
        commitWithStdin(dir, log, message, "git", "commit", "-F", "-");
    }

    /**
     * Alias for {@link #commit(File, Log, String)}; retained for callers
     * that emphasize the "files are already staged" reading. Editor-mode
     * (null-message) was removed in #277 — Maven's non-interactive child
     * process cannot open an editor, so the path was never reachable in
     * practice.
     *
     * @param dir     the repository root directory
     * @param log     Maven logger
     * @param message the commit message; must not be {@code null} or blank
     * @throws MojoException if the git command fails
     */
    public static void commitStaged(File dir, Log log, String message)
            throws MojoException {
        commit(dir, log, message);
    }

    /**
     * Stage all files.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @throws MojoException if the git command fails
     */
    public static void addAll(File dir, Log log) throws MojoException {
        run(dir, log, null, "git", "add", "-A");
    }

    /**
     * Push to remote. Sets {@code IKE_VCS_CONTEXT} to bypass the pre-push hook.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch to push
     * @throws MojoException if the git command fails
     */
    public static void push(File dir, Log log, String remote, String branch)
            throws MojoException {
        runWithContext(dir, log, "git", "push", remote, branch);
    }

    /**
     * Push to remote, ignoring failures (no remote, offline, etc.).
     * Logs a warning on failure instead of throwing.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch to push
     */
    public static void pushSafe(File dir, Log log, String remote, String branch) {
        try {
            push(dir, log, remote, branch);
        } catch (MojoException e) {
            log.warn("  Push failed (non-fatal): " + e.getMessage());
        }
    }

    /**
     * Push to remote if it exists. If no remote is configured, logs
     * a helpful message instead of failing with a cryptic git error.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch to push
     */
    public static void pushIfRemoteExists(File dir, Log log,
                                            String remote, String branch) {
        try {
            if (!network.ike.plugin.ReleaseSupport.hasRemote(dir, remote)) {
                log.info("  No remote '" + remote + "' configured for "
                        + dir.getName() + " — changes remain local.");
                return;
            }
            push(dir, log, remote, branch);
        } catch (MojoException e) {
            log.warn("  Push failed (non-fatal): " + e.getMessage());
        }
    }

    /**
     * Push to remote with upstream tracking.
     * Sets {@code IKE_VCS_CONTEXT} to bypass the pre-push hook.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch to push
     * @throws MojoException if the git command fails
     */
    public static void pushWithUpstream(File dir, Log log, String remote, String branch)
            throws MojoException {
        runWithContext(dir, log, "git", "push", "-u", remote, branch);
    }

    /**
     * Delete a local branch. Uses {@code -D} (force) because squash-merged
     * branches are not recognized as "fully merged" by git.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param branch the branch to delete
     * @throws MojoException if the git command fails
     */
    public static void deleteBranch(File dir, Log log, String branch)
            throws MojoException {
        run(dir, log, null, "git", "branch", "-D", branch);
    }

    /**
     * Delete a remote branch.
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name (e.g., "origin")
     * @param branch the branch to delete on the remote
     * @throws MojoException if the git command fails
     */
    public static void deleteRemoteBranch(File dir, Log log, String remote, String branch)
            throws MojoException {
        runWithContext(dir, log, "git", "push", remote, "--delete", branch);
    }

    /**
     * Squash-merge a branch into the current branch (does not commit).
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param branch the branch to squash-merge
     * @throws MojoException if the git command fails
     */
    public static void mergeSquash(File dir, Log log, String branch)
            throws MojoException {
        run(dir, log, null, "git", "merge", "--squash", branch);
    }

    /**
     * No-fast-forward merge with a merge commit.
     *
     * @param dir     the repository root directory
     * @param log     Maven logger
     * @param branch  the branch to merge
     * @param message the merge commit message
     * @throws MojoException if the git command fails
     */
    public static void mergeNoFf(File dir, Log log, String branch, String message)
            throws MojoException {
        runWithContext(dir, log, "git", "merge", "--no-ff", branch, "-m", message);
    }

    /**
     * Fast-forward-only merge. Used to advance the currently-checked-out
     * branch to a ref that is a strict descendant; fails if a real merge
     * (or rebase) would be required.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @param ref the ref to fast-forward to
     * @throws MojoException if the merge is not fast-forwardable or git fails
     */
    public static void mergeFfOnly(File dir, Log log, String ref)
            throws MojoException {
        runWithContext(dir, log, "git", "merge", "--ff-only", ref);
    }

    /**
     * Abort an in-progress merge ({@code git merge --abort}). Used as a
     * cleanup safety net after a merge attempt fails unexpectedly. Does
     * not throw if no merge is in progress.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     */
    public static void mergeAbortQuiet(File dir, Log log) {
        try {
            run(dir, log, null, "git", "merge", "--abort");
        } catch (MojoException e) {
            // No merge in progress, or already aborted — non-fatal here.
        }
    }

    /**
     * Check whether a local branch exists.
     *
     * @param dir    the repository root directory
     * @param branch the branch name to check
     * @return true if the branch exists locally
     */
    public static boolean localBranchExists(File dir, String branch) {
        try {
            String output = capture(dir, "git", "branch", "--list", branch);
            return !output.trim().isEmpty();
        } catch (MojoException e) {
            return false;
        }
    }

    /**
     * List local branches matching a prefix that are fully merged into
     * the given target branch.
     *
     * @param dir    the repository root directory
     * @param target the branch to check merge status against (e.g., "main")
     * @param prefix the branch name prefix to filter (e.g., "feature/")
     * @return list of merged branch names (trimmed, without leading {@code * })
     */
    public static List<String> mergedBranches(File dir, String target, String prefix) {
        try {
            String output = capture(dir, "git", "branch", "--merged", target);
            if (output.isEmpty()) return List.of();
            return output.lines()
                    .map(line -> line.replaceFirst("^[* ] +", ""))
                    .filter(b -> b.startsWith(prefix))
                    .filter(b -> !b.equals(target))
                    .toList();
        } catch (MojoException e) {
            return List.of();
        }
    }

    /**
     * List all local branches matching a prefix.
     *
     * @param dir    the repository root directory
     * @param prefix the branch name prefix to filter (e.g., "feature/")
     * @return list of branch names
     */
    public static List<String> localBranches(File dir, String prefix) {
        try {
            String output = capture(dir, "git", "branch");
            if (output.isEmpty()) return List.of();
            return output.lines()
                    .map(line -> line.replaceFirst("^[* ] +", ""))
                    .filter(b -> b.startsWith(prefix))
                    .toList();
        } catch (MojoException e) {
            return List.of();
        }
    }

    /**
     * Get the date of the last commit on a branch (ISO format).
     *
     * @param dir    the repository root directory
     * @param branch the branch name
     * @return the commit date string, or "unknown" on failure
     */
    public static String branchLastCommitDate(File dir, String branch) {
        try {
            return capture(dir, "git", "log", "-1", "--format=%ci", branch);
        } catch (MojoException e) {
            return "unknown";
        }
    }

    // ── Auto-stash via pushable refs (ws:switch, #153) ────────────

    /**
     * Read {@code git config user.email} for the repository.
     *
     * @param dir the repository root directory
     * @return the configured email address
     * @throws MojoException if no email is configured
     */
    public static String userEmail(File dir) throws MojoException {
        String email = capture(dir, "git", "config", "user.email").trim();
        if (email.isEmpty()) {
            throw new MojoException(
                    "git config user.email is not set in " + dir);
        }
        return email;
    }

    /**
     * Derive an ASCII-safe slug from a git email address. Used as the
     * per-user component of the {@code refs/ws-stash/<slug>/<branch>}
     * ref naming scheme. Lowercases, replaces {@code @} with
     * {@code --}, and {@code .} with {@code -}.
     *
     * @param email the email address (typically from {@link #userEmail})
     * @return an ASCII slug safe for ref names and cross-platform shells
     */
    public static String userSlug(String email) {
        return email.toLowerCase()
                .replace("@", "--")
                .replace(".", "-");
    }

    /**
     * Check whether a remote ref exists by shelling out to
     * {@code git ls-remote}. Distinguishes "ref absent" from
     * "remote unreachable": a zero-exit with empty stdout means absent,
     * a zero-exit with output means present, and a non-zero exit
     * surfaces the network/auth error to the caller.
     *
     * @param dir    the repository root directory
     * @param remote the remote name (e.g., {@code "origin"})
     * @param ref    the full ref to probe (e.g.,
     *               {@code "refs/ws-stash/kec--knowledge-design/feature/A"})
     * @return {@code true} if the ref exists on the remote,
     *         {@code false} if absent
     * @throws MojoException if the remote is unreachable or the probe fails
     */
    public static boolean remoteRefExists(File dir, String remote, String ref)
            throws MojoException {
        String output = capture(dir, "git", "ls-remote", remote, ref);
        return !output.isEmpty();
    }

    /**
     * Stash the working tree including untracked files
     * ({@code git stash push -u -m <message>}). Ignored files are
     * skipped.
     *
     * @param dir     the repository root directory
     * @param log     Maven logger
     * @param message the stash message
     * @throws MojoException if the git command fails
     */
    public static void stashPushUntracked(File dir, Log log, String message)
            throws MojoException {
        run(dir, log, null, "git", "stash", "push", "-u", "-m", message);
    }

    /**
     * Apply a stash identified by its ref ({@code git stash apply <ref>}).
     * Unlike {@code git stash apply} with a numbered index, this form
     * accepts a full ref path (e.g. {@code refs/ws-stash/slug/branch}).
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @param ref the stash ref to apply
     * @throws MojoException if the git command fails
     */
    public static void stashApply(File dir, Log log, String ref)
            throws MojoException {
        run(dir, log, null, "git", "stash", "apply", ref);
    }

    /**
     * Drop the top of the stash stack ({@code git stash drop}).
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @throws MojoException if the git command fails
     */
    public static void stashDrop(File dir, Log log) throws MojoException {
        run(dir, log, null, "git", "stash", "drop");
    }

    /**
     * Point a ref at a given target ({@code git update-ref <ref> <target>}).
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param ref    the ref to update (full path, e.g.
     *               {@code "refs/ws-stash/slug/branch"})
     * @param target the ref or SHA to point at
     * @throws MojoException if the git command fails
     */
    public static void updateRef(File dir, Log log, String ref, String target)
            throws MojoException {
        run(dir, log, null, "git", "update-ref", ref, target);
    }

    /**
     * Delete a local ref ({@code git update-ref -d <ref>}).
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @param ref the ref to delete
     * @throws MojoException if the git command fails
     */
    public static void deleteLocalRef(File dir, Log log, String ref)
            throws MojoException {
        run(dir, log, null, "git", "update-ref", "-d", ref);
    }

    /**
     * Push a ref to a remote under the same name
     * ({@code git push <remote> <ref>:<ref>}).
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name
     * @param ref    the ref to push
     * @throws MojoException if the git command fails
     */
    public static void pushRef(File dir, Log log, String remote, String ref)
            throws MojoException {
        run(dir, log, null, "git", "push", remote, ref + ":" + ref);
    }

    /**
     * Delete a ref from a remote ({@code git push <remote> :<ref>}).
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name
     * @param ref    the ref to delete on the remote
     * @throws MojoException if the git command fails
     */
    public static void deleteRemoteRef(File dir, Log log,
                                        String remote, String ref)
            throws MojoException {
        run(dir, log, null, "git", "push", remote, ":" + ref);
    }

    /**
     * Fetch a remote ref into a local ref of the same name
     * ({@code git fetch <remote> <ref>:<ref>}).
     *
     * @param dir    the repository root directory
     * @param log    Maven logger
     * @param remote the remote name
     * @param ref    the ref to fetch
     * @throws MojoException if the git command fails
     */
    public static void fetchRef(File dir, Log log, String remote, String ref)
            throws MojoException {
        run(dir, log, null, "git", "fetch", remote, ref + ":" + ref);
    }

    // ── VCS state operations ─────────────────────────────────────

    /**
     * Write the VCS state file for the given directory.
     *
     * @param dir    the repository root directory
     * @param action the action being performed
     * @throws MojoException if writing the state file fails
     */
    public static void writeVcsState(File dir, VcsState.Action action)
            throws MojoException {
        try {
            String branch = currentBranch(dir);
            String sha = headSha(dir);
            VcsState state = VcsState.create(branch, sha, action);
            VcsState.writeTo(dir.toPath(), state);
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to write VCS state file: " + e.getMessage(), e);
        }
    }

    /**
     * Check whether the local HEAD has fallen behind the VCS state
     * file written by a coordinated workspace operation. Returns
     * {@code false} when the state file is simply stale relative to
     * later local commits — this is the common case after
     * {@code git commit --amend} or any subsequent commit that didn't
     * route through a {@code ws:*} goal (ike-issues#232).
     *
     * <p>Decision table:
     * <ul>
     *   <li>No state file → {@code false} (nothing to sync against).</li>
     *   <li>Branch mismatch → {@code true} (state file expects a
     *       different branch).</li>
     *   <li>{@code state.sha == localSha} → {@code false}.</li>
     *   <li>{@code state.sha} is a strict ancestor of {@code localSha}
     *       → {@code false} (state file is stale, but HEAD is just
     *       ahead — no cross-machine pull needed).</li>
     *   <li>Anything else (state.sha unknown, descendant of HEAD, or
     *       diverged) → {@code true} (genuine catch-up required).</li>
     * </ul>
     *
     * @param dir the repository root directory
     * @return {@code true} when {@code sync} should run, {@code false}
     *         when the working tree is already current
     * @throws MojoException if reading basic git state fails
     */
    public static boolean needsSync(File dir) throws MojoException {
        Optional<VcsState> state = VcsState.readFrom(dir.toPath());
        if (state.isEmpty()) {
            return false;
        }
        VcsState s = state.get();
        String localBranch = currentBranch(dir);
        if (!s.branch().equals(localBranch)) {
            return true;
        }
        String localSha = headSha(dir);
        if (s.sha().equals(localSha)) {
            return false;
        }
        // Common case after a routine local commit: state.sha is an
        // ancestor of HEAD, so the state file is just stale relative to
        // post-ws:commit work. No sync needed.
        try {
            if (isAncestor(dir, s.sha(), localSha)) {
                return false;
            }
        } catch (MojoException e) {
            // state.sha unknown locally — could be a commit pushed
            // from another machine that we haven't fetched yet.
            // Treat as needing sync.
        }
        return true;
    }

    /**
     * Synchronize local git state to match the VCS state file.
     * Fetches from all remotes, switches branch if needed, and soft-resets.
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @return the resulting HEAD SHA after sync
     * @throws MojoException if a git command or state file read fails
     */
    public static String sync(File dir, Log log) throws MojoException {
        Optional<VcsState> stateOpt = VcsState.readFrom(dir.toPath());
        if (stateOpt.isEmpty()) {
            log.info("  No VCS state file — nothing to sync.");
            return headSha(dir);
        }

        VcsState state = stateOpt.get();
        log.info("  Syncing to: " + state.action().label() + " by " + state.machine()
                + " at " + state.timestamp());

        fetch(dir, log);

        String localBranch = currentBranch(dir);
        if (!state.branch().equals(localBranch)) {
            if (!localBranchExists(dir, state.branch())) {
                // Branch doesn't exist locally — check remote
                Optional<String> remoteCheck = remoteSha(dir, "origin", state.branch());
                if (remoteCheck.isEmpty()) {
                    log.warn("  Branch " + state.branch()
                            + " does not exist locally or on origin.");
                    log.warn("  The branch may not have been pushed from "
                            + state.machine() + " yet.");
                    log.warn("  Push from " + state.machine()
                            + " first, then retry sync.");
                    return headSha(dir);
                }
                // Create local tracking branch from remote
                log.info("  Creating local branch from origin: " + state.branch());
                run(dir, log, null, "git", "checkout", "-b",
                        state.branch(), "origin/" + state.branch());
            } else {
                log.info("  Switching branch: " + localBranch + " → " + state.branch());
                checkout(dir, log, state.branch());
            }
        }

        Optional<String> remoteRef = remoteSha(dir, "origin", state.branch());
        if (remoteRef.isEmpty()) {
            log.info("  No remote ref for " + state.branch()
                    + " on origin — branch is local-only, using local state.");
            return headSha(dir);
        }

        // Evaluate the relationship between local branch tip and origin
        // before touching HEAD. An unconditional reset-to-origin would
        // silently discard unpushed local commits (#144).
        String localSha = headSha(dir);
        String remoteShaValue = remoteRef.get();

        if (localSha.equals(remoteShaValue)) {
            log.info("  Already at origin/" + state.branch()
                    + " — no reset needed.");
        } else if (isAncestor(dir, remoteShaValue, localSha)) {
            // Local is strictly ahead of origin. Preserve the unpushed
            // commits — the caller (usually ws:push) will push them.
            log.info("  Local " + state.branch()
                    + " is ahead of origin — keeping unpushed commits.");
        } else if (isAncestor(dir, localSha, remoteShaValue)) {
            // Local is strictly behind origin. Fast-forward is safe
            // (equivalent to git pull --ff-only).
            log.info("  Fast-forwarding " + state.branch() + " to origin.");
            resetSoft(dir, log, "origin/" + state.branch());
        } else {
            // Diverged — local and origin each have unique commits.
            // Refuse to silently pick a side; ask the human.
            throw new MojoException(
                    "Local " + state.branch() + " (" + localSha
                            + ") has diverged from origin/" + state.branch()
                            + " (" + remoteShaValue + ") — neither is an "
                            + "ancestor of the other. Resolve manually "
                            + "(git pull --rebase, git rebase origin/"
                            + state.branch() + ", or ws:update-feature), "
                            + "then retry.");
        }

        String newSha = headSha(dir);
        if (newSha.equals(state.sha())) {
            log.info("  HEAD now matches state file: " + newSha);
            return newSha;
        }
        // After fast-forward / no-op, HEAD may legitimately sit ahead
        // of state.sha — the state file is just stale relative to
        // later local commits, not a sign of a missing push
        // (ike-issues#232). Only warn when state.sha is genuinely
        // unreachable from HEAD.
        try {
            if (isAncestor(dir, state.sha(), newSha)) {
                log.info("  HEAD (" + newSha + ") is ahead of state file ("
                        + state.sha() + ") — state file is stale, no action needed.");
                return newSha;
            }
        } catch (MojoException ignored) {
            // state.sha unreachable locally — fall through to warning.
        }
        log.warn("  HEAD after sync (" + newSha + ") does not match state file ("
                + state.sha() + ").");
        log.warn("  The push from " + state.machine() + " may not have completed.");
        log.warn("  Push from " + state.machine() + " first, then retry sync.");

        return newSha;
    }

    /**
     * Catch-up preamble: sync if needed, otherwise report that we're current.
     * Used by all goals that modify state (commit, push, feature-start, etc.).
     *
     * @param dir the repository root directory
     * @param log Maven logger
     * @throws MojoException if sync fails
     */
    public static void catchUp(File dir, Log log) throws MojoException {
        if (!VcsState.isIkeManaged(dir.toPath())) {
            return;
        }
        if (needsSync(dir)) {
            log.info("  VCS state is behind — catching up...");
            sync(dir, log);
        }
    }

    // ── Internal helpers ─────────────────────────────────────────

    /**
     * Run a command with output routed through the Maven logger.
     * Optionally sets environment variables.
     */
    private static void run(File workDir, Log log, Map<String, String> env,
                            String... command) throws MojoException {
        log.debug("» " + String.join(" ", command));
        try {
            ProcessBuilder pb = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(true);
            if (env != null) {
                pb.environment().putAll(env);
            }
            Process proc = pb.start();
            try (var reader = new BufferedReader(
                    new InputStreamReader(proc.getInputStream(),
                            StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    log.debug("  " + line);
                }
            }
            int exit = proc.waitFor();
            if (exit != 0) {
                throw new MojoException(
                        "Command failed (exit " + exit + "): "
                                + String.join(" ", command));
            }
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }

    /**
     * Run a command with {@code IKE_VCS_CONTEXT} set in the environment.
     */
    private static void runWithContext(File workDir, Log log, String... command)
            throws MojoException {
        run(workDir, log, Map.of(IKE_VCS_CONTEXT, CONTEXT_VALUE), command);
    }

    /**
     * Run a git command that reads its message from stdin via {@code -F -}.
     *
     * <p>This supports multi-line messages reliably — no shell quoting
     * issues, no argument-length limits. The command array should include
     * {@code "-F", "-"} where the message would normally follow {@code -m}.
     *
     * <p>Sets {@code IKE_VCS_CONTEXT} to bypass the pre-commit hook.
     *
     * @param workDir the repository root directory
     * @param log     Maven logger
     * @param message the message to write to stdin
     * @param command the git command (including {@code -F -})
     * @throws MojoException if the command fails
     */
    private static void commitWithStdin(File workDir, Log log,
                                         String message, String... command)
            throws MojoException {
        log.debug("» " + String.join(" ", command) + " <<< (message via stdin)");
        try {
            ProcessBuilder pb = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(true);
            pb.environment().put(IKE_VCS_CONTEXT, CONTEXT_VALUE);
            Process proc = pb.start();

            // Write message to stdin, then close to signal EOF
            try (var out = proc.getOutputStream()) {
                out.write(message.getBytes(StandardCharsets.UTF_8));
                out.flush();
            }

            // Consume stdout/stderr
            String output;
            try (var reader = new BufferedReader(
                    new InputStreamReader(proc.getInputStream(),
                            StandardCharsets.UTF_8))) {
                output = reader.lines().collect(Collectors.joining("\n"));
            }

            int exit = proc.waitFor();
            if (exit != 0) {
                throw new MojoException(
                        "Command failed (exit " + exit + "): "
                                + String.join(" ", command)
                                + (output.isEmpty() ? "" : "\n" + output));
            }
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }

    /**
     * Run a command and capture stdout as a trimmed string.
     */
    private static String capture(File workDir, String... command)
            throws MojoException {
        try {
            Process proc = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(false)
                    .start();
            String output;
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(proc.getInputStream(),
                            StandardCharsets.UTF_8))) {
                output = reader.lines().collect(Collectors.joining("\n")).trim();
            }
            int exit = proc.waitFor();
            if (exit != 0) {
                throw new MojoException(
                        "Command failed (exit " + exit + "): "
                                + String.join(" ", command));
            }
            return output;
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }
}