SiblingInventory.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.FeatureName;

import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

/**
 * Discovery and state assessment for a primary's sibling working sets —
 * the shared substrate of {@code ws:sibling-list} (IKE-Network/ike-issues#599)
 * and {@code ws:sibling-remove} (IKE-Network/ike-issues#600).
 *
 * <p>A sibling is named {@code <primary>꞉<feature>} and lives beside its
 * primary (IKE-Network/ike-issues#992). Discovery scans the primary's
 * parent directory for that pattern and classifies each candidate:
 * <em>conformant</em> when {@link SiblingFinish#localParent} confirms the
 * chain back to this primary (recorded parent, or derivation confirmed
 * against a local origin), <em>legacy</em> otherwise — the shape a sibling
 * created before the local-origin model still has until repaired
 * (IKE-Network/ike-issues#1057).
 *
 * <p>Assessment reports, per member repository, exactly the things that
 * die with a deleted tree: uncommitted changes, <b>stashes</b> (the one
 * thing no upstream comparison ever reveals), and unlanded branches. A
 * branch counts as landed when its commits are contained in origin's refs
 * <em>or its tree equals an origin tip's tree</em> — the squash finish
 * never preserves SHAs, and the tree is the medium of exchange.
 */
final class SiblingInventory {

    /** Directories never descended into while discovering member repos. */
    private static final List<String> SKIPPED_DIRECTORIES =
            List.of(".git", ".ike", ".idea", ".stversions", "target",
                    "checkpoints");

    /** How deep member discovery looks below the sibling root. */
    private static final int MEMBER_DISCOVERY_DEPTH = 3;

    private SiblingInventory() {}

    /**
     * One discovered sibling.
     *
     * @param name       the sibling directory name ({@code <primary>꞉<feature>})
     * @param root       the sibling's directory
     * @param feature    the feature half of the name
     * @param conformant {@code true} when the local-origin chain back to
     *                   the primary is confirmed
     */
    record Sibling(String name, File root, String feature,
                   boolean conformant) {}

    /**
     * One member repository's removable-state assessment.
     *
     * @param path        the member path relative to the sibling root, or
     *                    {@code "(root)"} for the sibling root itself
     * @param bare        tree present but no git state — unverifiable
     * @param branch      the current branch, or {@code "—"} when bare
     * @param uncommitted count of uncommitted paths ({@code status --porcelain})
     * @param stashes     count of stash entries
     * @param unlanded    names of local branches whose work is neither
     *                    contained in origin's refs nor tree-equal to an
     *                    origin tip; {@code null} when it could not be
     *                    determined (no origin, or the fetch failed)
     * @param originLocal {@code true} when origin is a local path — the
     *                    #992 invariant; {@code false} for a legacy remote
     *                    remote or a missing origin
     */
    record MemberState(String path, boolean bare, String branch,
                       int uncommitted, int stashes, List<String> unlanded,
                       boolean originLocal) {

        /** @return {@code true} when nothing in this member blocks removal */
        boolean removable() {
            return !bare && uncommitted == 0 && stashes == 0
                    && unlanded != null && unlanded.isEmpty();
        }
    }

    /**
     * Discovers the primary's siblings: entries beside the primary named
     * {@code <primary>꞉*}, classified by local-origin conformance.
     *
     * @param primaryRoot the primary working set's root directory
     * @param baseName    the primary's directory name
     * @return the siblings, name-ordered; empty when the primary has none
     */
    static List<Sibling> discover(File primaryRoot, String baseName) {
        File parent = primaryRoot.getParentFile();
        if (parent == null) {
            return List.of();
        }
        String prefix = baseName + FeatureName.SIBLING_SEPARATOR;
        File[] entries = parent.listFiles(File::isDirectory);
        if (entries == null) {
            return List.of();
        }
        List<Sibling> siblings = new ArrayList<>();
        for (File entry : entries) {
            String name = entry.getName();
            if (!name.startsWith(prefix)
                    || name.length() == prefix.length()) {
                continue;
            }
            boolean conformant = SiblingFinish.localParent(entry)
                    .map(found -> sameDirectory(found, primaryRoot))
                    .orElse(false);
            siblings.add(new Sibling(name, entry,
                    name.substring(prefix.length()), conformant));
        }
        siblings.sort(Comparator.comparing(Sibling::name));
        return siblings;
    }

    /**
     * Assesses every member repository of a sibling.
     *
     * @param siblingRoot    the sibling's directory
     * @param fetchLocalOnly when {@code true}, origins that are remote URLs
     *                       are not fetched (a listing must not touch the
     *                       network); when {@code false} every origin is
     *                       fetched first, so the landed test is current
     * @return one state per member, the root first
     */
    static List<MemberState> assess(File siblingRoot, boolean fetchLocalOnly) {
        List<MemberState> states = new ArrayList<>();
        for (File member : memberDirectories(siblingRoot)) {
            String label = member.equals(siblingRoot) ? "(root)"
                    : siblingRoot.toPath().relativize(member.toPath())
                            .toString();
            if (!hasRealGit(member)) {
                states.add(new MemberState(label, true, "—", 0, 0, null,
                        false));
                continue;
            }
            states.add(assessRepo(label, member, fetchLocalOnly));
        }
        return states;
    }

    private static MemberState assessRepo(String label, File repo,
                                          boolean fetchLocalOnly) {
        String branch = capture(repo, "rev-parse", "--abbrev-ref", "HEAD")
                .orElse("—");
        int uncommitted = lineCount(capture(repo, "status", "--porcelain"));
        int stashes = lineCount(capture(repo, "stash", "list"));
        Optional<String> origin = capture(repo, "remote", "get-url", "origin");
        boolean originLocal = origin.map(SiblingInventory::isLocalPath)
                .orElse(false);
        List<String> unlanded = origin.isEmpty() ? null
                : unlandedBranches(repo, originLocal || !fetchLocalOnly);
        return new MemberState(label, false, branch, uncommitted, stashes,
                unlanded, originLocal);
    }

    /**
     * Counts local branches whose work has not landed at origin. Fetches
     * first when allowed, so the answer is current; a branch is landed
     * when its commits are contained in origin's refs, or when its tree
     * equals some origin tip's tree — the shape a squash finish leaves.
     */
    private static List<String> unlandedBranches(File repo, boolean fetch) {
        if (fetch && !run(repo, "fetch", "--quiet", "origin")) {
            return null;
        }
        Optional<String> branches = capture(repo, "for-each-ref",
                "refs/heads", "--format=%(refname:short)");
        if (branches.isEmpty()) {
            return List.of();       // no local branches: nothing to lose
        }
        List<String> originTrees = new ArrayList<>();
        capture(repo, "for-each-ref", "refs/remotes/origin",
                "--format=%(refname)").orElse("").lines()
                .filter(ref -> !ref.isBlank())
                .forEach(ref -> capture(repo, "rev-parse", ref + "^{tree}")
                        .ifPresent(originTrees::add));
        List<String> unlanded = new ArrayList<>();
        for (String branch : branches.get().lines()
                .filter(line -> !line.isBlank()).toList()) {
            String contained = capture(repo, "rev-list", "--count", branch,
                    "--not", "--remotes=origin").orElse("?");
            if ("0".equals(contained)) {
                continue;
            }
            String tree = capture(repo, "rev-parse", branch + "^{tree}")
                    .orElse("?");
            if (originTrees.contains(tree)) {
                continue;
            }
            if (onlyGoalAuthoredCommits(repo, branch)) {
                continue;
            }
            unlanded.add(branch);
        }
        return unlanded;
    }

    /**
     * Reports whether every commit the branch holds beyond origin is
     * goal-authored bookkeeping. The {@code ws:} machinery commits its own
     * manifest and version bookkeeping under the {@code "ws: "} subject
     * namespace ({@code GoalAuthoredChanges.commitAuthored}, #780) — the
     * finish deliberately leaves such commits sibling-local (a sibling's
     * derived depends-on edges are its own), and they must not force a
     * {@code -Dforce} on every finished workspace sibling. A hand-written
     * commit inside that namespace would ride along; the namespace is the
     * machinery's, and the draft report names the branch either way.
     */
    private static boolean onlyGoalAuthoredCommits(File repo, String branch) {
        Optional<String> subjects = capture(repo, "log", "--format=%s",
                branch, "--not", "--remotes=origin");
        return subjects.isPresent()
                && subjects.get().lines()
                        .filter(line -> !line.isBlank())
                        .allMatch(subject -> subject.startsWith("ws: "));
    }

    /**
     * Classifies a git origin as a local filesystem path or a remote URL —
     * the same predicate the materializer core applies
     * (IKE-Network/ike-issues#1057).
     *
     * @param origin the configured origin
     * @return {@code true} for filesystem paths ({@code file://} included)
     */
    static boolean isLocalPath(String origin) {
        if (origin.startsWith("file://")) {
            return true;
        }
        if (origin.contains("://")) {
            return false;
        }
        return !origin.matches("^[^/@]+@[^/:]+:.*");
    }

    /**
     * The sibling root plus every git repository below it, without
     * descending into repositories, hidden directories, or build output.
     */
    private static List<File> memberDirectories(File siblingRoot) {
        List<File> members = new ArrayList<>();
        members.add(siblingRoot);
        collectMembers(siblingRoot, 0, members);
        return members;
    }

    private static void collectMembers(File directory, int depth,
                                       List<File> members) {
        if (depth >= MEMBER_DISCOVERY_DEPTH) {
            return;
        }
        File[] children = directory.listFiles(File::isDirectory);
        if (children == null) {
            return;
        }
        List<File> sorted = new ArrayList<>(List.of(children));
        sorted.sort(Comparator.comparing(File::getName));
        for (File child : sorted) {
            String name = child.getName();
            if (name.startsWith(".") || SKIPPED_DIRECTORIES.contains(name)) {
                continue;
            }
            if (hasRealGit(child)) {
                members.add(child);
            } else {
                collectMembers(child, depth + 1, members);
            }
        }
    }

    /**
     * Real git state, as opposed to a sync-layer husk. The synced folder's
     * {@code (?d).git/} ignore carries the {@code .git} directory entry but
     * none of its contents, so peer machines hold empty {@code .git} husks
     * (measured fleet-wide 2026-08-22); a gitfile, or a directory with a
     * {@code HEAD}, is the honest test.
     *
     * @param directory the candidate repository directory
     * @return {@code true} when git state is actually present
     */
    private static boolean hasRealGit(File directory) {
        File git = new File(directory, ".git");
        return git.isFile() || new File(git, "HEAD").exists();
    }

    private static boolean sameDirectory(File a, File b) {
        try {
            return a.getCanonicalFile().equals(b.getCanonicalFile());
        } catch (java.io.IOException e) {
            return a.getAbsoluteFile().equals(b.getAbsoluteFile());
        }
    }

    private static Optional<String> capture(File repo, String... args) {
        try {
            String[] command = new String[args.length + 1];
            command[0] = "git";
            System.arraycopy(args, 0, command, 1, args.length);
            String out = ReleaseSupport.execCapture(repo, command);
            return Optional.ofNullable(out).map(String::trim);
        } catch (Exception e) {
            return Optional.empty();
        }
    }

    private static boolean run(File repo, String... args) {
        try {
            String[] command = new String[args.length + 1];
            command[0] = "git";
            System.arraycopy(args, 0, command, 1, args.length);
            ReleaseSupport.execCapture(repo, command);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private static int lineCount(Optional<String> output) {
        return (int) output.stream()
                .flatMap(String::lines)
                .filter(line -> !line.isBlank())
                .count();
    }
}