aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/core/discover.ts
blob: 3511b3e44e9ed601dd9ab80e89725258b263251a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
 * Project discovery.
 *
 * A project is a directory under the root containing a `.sl0ppi/` marker.
 * Depth-1 by default: recursively scanning ~/dev is slow and surprising, and
 * an explicit `sl0ppi open path/to/nested` covers the rest.
 */

import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";

export const MARKER_DIR = ".sl0ppi";
export const LAYOUT_FILE = "layout.kdl";

export interface DiscoverOptions {
  /** How many directory levels below the root to search. Default 1. */
  depth?: number;
}

/**
 * Paths of discovered projects, relative to `root`, sorted alphabetically.
 *
 * Unreadable directories are skipped rather than fatal: one bad-permission
 * directory in ~/dev must not stop the other eleven projects from opening.
 */
export function discoverProjects(root: string, options: DiscoverOptions = {}): string[] {
  const depth = options.depth ?? 1;
  const found: string[] = [];
  walk(root, "", depth, found);
  return found.sort();
}

function walk(root: string, rel: string, depthLeft: number, found: string[]): void {
  if (depthLeft <= 0) return;

  let entries;
  try {
    entries = readdirSync(join(root, rel), { withFileTypes: true });
  } catch {
    return; // unreadable: skip, do not throw
  }

  for (const entry of entries) {
    // Skip dotfiles: `.git` and friends are never projects, and this also
    // stops us descending into the marker directory itself.
    if (entry.name.startsWith(".")) continue;

    const childRel = rel ? `${rel}/${entry.name}` : entry.name;
    const childAbs = join(root, childRel);

    // Follow symlinked directories (a worktree or a symlinked checkout is a
    // perfectly ordinary way to lay out ~/dev), but never fail on a broken one.
    let isDir = entry.isDirectory();
    if (!isDir && entry.isSymbolicLink()) {
      try {
        isDir = statSync(childAbs).isDirectory();
      } catch {
        continue; // dangling symlink
      }
    }
    if (!isDir) continue;

    if (isProjectDir(childAbs)) {
      found.push(childRel);
      continue; // a project is a leaf; do not nest projects inside projects
    }

    walk(root, childRel, depthLeft - 1, found);
  }
}

/** A directory is a project when it carries the `.sl0ppi/` marker directory. */
export function isProjectDir(absPath: string): boolean {
  try {
    return statSync(join(absPath, MARKER_DIR)).isDirectory();
  } catch {
    return false;
  }
}