@doriandev/devcc 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -0,0 +1,133 @@
1
+ import { dirname } from "node:path"
2
+ import { firstLine } from "./tools.js"
3
+
4
+ /**
5
+ * Pure parsers for mise and direnv. Both tools offer machine-readable output,
6
+ * so nothing here scrapes their human-oriented formatting.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // mise
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface RuntimeVersion {
14
+ tool: string
15
+ version: string
16
+ /** What the config asked for, e.g. `22` or `lts`. */
17
+ requested?: string
18
+ /** Config file that pinned it. */
19
+ source?: string
20
+ installed: boolean
21
+ active: boolean
22
+ }
23
+
24
+ export interface MiseStatus {
25
+ installed: boolean
26
+ runtimes: RuntimeVersion[]
27
+ /** Set when mise is installed but could not report (e.g. untrusted config). */
28
+ error?: string
29
+ }
30
+
31
+ /** `mise ls --current --json`: an object keyed by tool name, each an array of version records. */
32
+ export const MISE_ARGS = ["ls", "--current", "--json"] as const
33
+
34
+ export function parseMiseLs(output: string): RuntimeVersion[] {
35
+ let data: unknown
36
+ try {
37
+ data = JSON.parse(output)
38
+ } catch {
39
+ return []
40
+ }
41
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return []
42
+
43
+ const runtimes: RuntimeVersion[] = []
44
+ for (const [tool, records] of Object.entries(data as Record<string, unknown>)) {
45
+ if (!Array.isArray(records)) continue
46
+ for (const record of records) {
47
+ if (typeof record !== "object" || record === null) continue
48
+ const entry = record as Record<string, unknown>
49
+ const version = typeof entry.version === "string" ? entry.version : undefined
50
+ if (!version) continue
51
+ const source = entry.source as Record<string, unknown> | undefined
52
+ runtimes.push({
53
+ tool,
54
+ version,
55
+ requested: typeof entry.requested_version === "string" ? entry.requested_version : undefined,
56
+ source: typeof source?.path === "string" ? source.path : undefined,
57
+ installed: entry.installed !== false,
58
+ active: entry.active === true,
59
+ })
60
+ }
61
+ }
62
+ return runtimes.sort((a, b) => a.tool.localeCompare(b.tool))
63
+ }
64
+
65
+ /** Turn a failed `mise ls` into a short, actionable message. */
66
+ export function miseErrorMessage(stderr: string): string {
67
+ if (/not trusted/iu.test(stderr)) return "config is not trusted - run `mise trust` if you expect it"
68
+ return firstLine(stderr.replace(/^mise\s+(ERROR|WARN)\s*/imu, "")) ?? "mise ls failed"
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // direnv
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export type DirenvApproval = "allowed" | "not-allowed" | "denied" | "unknown"
76
+
77
+ export interface DirenvStatus {
78
+ installed: boolean
79
+ /** The `.envrc` direnv would load for this project, if any. */
80
+ rcPath?: string
81
+ approval: DirenvApproval
82
+ /**
83
+ * Whether devcc's own environment has this `.envrc` loaded (it was started
84
+ * from a direnv-enabled shell in the project). Null when there is nothing to load.
85
+ */
86
+ loaded: boolean | null
87
+ error?: string
88
+ }
89
+
90
+ /** direnv's numeric allow states (`internal/cmd/rc.go`). */
91
+ const APPROVAL_CODES: Record<number, DirenvApproval> = { 0: "allowed", 1: "not-allowed", 2: "denied" }
92
+
93
+ function approvalFrom(value: unknown): DirenvApproval {
94
+ if (typeof value === "number") return APPROVAL_CODES[value] ?? "unknown"
95
+ if (typeof value === "boolean") return value ? "allowed" : "not-allowed"
96
+ if (typeof value === "string") {
97
+ const numeric = Number(value)
98
+ if (value.trim() !== "" && Number.isInteger(numeric)) return APPROVAL_CODES[numeric] ?? "unknown"
99
+ if (value === "true") return "allowed"
100
+ if (value === "false") return "not-allowed"
101
+ }
102
+ return "unknown"
103
+ }
104
+
105
+ /**
106
+ * Parse `direnv status --json`, falling back to the plain `direnv status`
107
+ * text for versions without JSON support.
108
+ */
109
+ export function parseDirenvStatus(output: string): { rcPath?: string; approval: DirenvApproval } {
110
+ try {
111
+ const data = JSON.parse(output) as { state?: { foundRC?: { path?: unknown; allowed?: unknown } | null } }
112
+ const found = data.state?.foundRC
113
+ if (!found || typeof found.path !== "string") return { approval: "unknown" }
114
+ return { rcPath: found.path, approval: approvalFrom(found.allowed) }
115
+ } catch {
116
+ // Not JSON: older direnv. Fall through to the text format.
117
+ }
118
+
119
+ const path = /^Found RC path (.+)$/mu.exec(output)?.[1]?.trim()
120
+ if (!path) return { approval: "unknown" }
121
+ const allowed = /^Found RC allowed (.+)$/mu.exec(output)?.[1]?.trim()
122
+ return { rcPath: path, approval: allowed === undefined ? "unknown" : approvalFrom(allowed) }
123
+ }
124
+
125
+ /**
126
+ * direnv exports `DIRENV_DIR=-<dir>` into shells where it loaded an `.envrc`.
127
+ * If devcc inherited that for this `.envrc`'s directory, the environment is loaded.
128
+ */
129
+ export function isDirenvLoaded(rcPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
130
+ const dir = env.DIRENV_DIR
131
+ if (!dir) return false
132
+ return dir.replace(/^-/u, "") === dirname(rcPath)
133
+ }
@@ -0,0 +1,151 @@
1
+ import { accessSync, constants } from "node:fs"
2
+ import { delimiter, isAbsolute, join } from "node:path"
3
+ import { exec, type ExecOptions, type ExecResult } from "../utils/exec.js"
4
+
5
+ /**
6
+ * Tool detection for the Environment panel and `devcc env`.
7
+ *
8
+ * Observation only: devcc reports what is installed and whether it answers, it
9
+ * never installs or upgrades anything. A missing tool is a normal state, not
10
+ * an error.
11
+ */
12
+
13
+ export type ToolHealth = "ready" | "warning" | "error"
14
+
15
+ export interface ToolStatus {
16
+ id: string
17
+ name: string
18
+ installed: boolean
19
+ version?: string
20
+ /** Resolved executable path. */
21
+ path?: string
22
+ /** Absent when the tool is not installed. */
23
+ status?: ToolHealth
24
+ message?: string
25
+ }
26
+
27
+ export interface ToolDefinition {
28
+ id: string
29
+ name: string
30
+ command: string
31
+ /** Arguments that print a version (default `--version`). */
32
+ versionArgs?: string[]
33
+ /** Capture group 1 is the version; defaults to the first dotted number. */
34
+ versionPattern?: RegExp
35
+ /** Absolute paths to try when the command is not on PATH (e.g. app bundles). */
36
+ fallbackPaths?: string[]
37
+ }
38
+
39
+ export const DEFAULT_TOOLS: readonly ToolDefinition[] = [
40
+ { id: "node", name: "Node", command: "node" },
41
+ { id: "pnpm", name: "pnpm", command: "pnpm" },
42
+ { id: "npm", name: "npm", command: "npm" },
43
+ { id: "bun", name: "Bun", command: "bun" },
44
+ { id: "git", name: "Git", command: "git" },
45
+ { id: "mise", name: "mise", command: "mise" },
46
+ { id: "direnv", name: "direnv", command: "direnv", versionArgs: ["version"] },
47
+ { id: "docker", name: "Docker", command: "docker" },
48
+ { id: "cargo", name: "Rust/Cargo", command: "cargo" },
49
+ { id: "claude", name: "Claude Code", command: "claude" },
50
+ { id: "codex", name: "Codex", command: "codex" },
51
+ {
52
+ id: "tailscale",
53
+ name: "Tailscale",
54
+ command: "tailscale",
55
+ versionArgs: ["version"],
56
+ fallbackPaths: ["/Applications/Tailscale.app/Contents/MacOS/Tailscale"],
57
+ },
58
+ { id: "ollama", name: "Ollama", command: "ollama" },
59
+ ]
60
+
61
+ export const TOOL_TIMEOUT_MS = 3_000
62
+
63
+ /** First `x.y[.z…]` version in the output, or the pattern's first group. */
64
+ export function parseVersion(output: string, pattern?: RegExp): string | undefined {
65
+ const text = output.trim()
66
+ if (text === "") return undefined
67
+ if (pattern) {
68
+ const match = pattern.exec(text)
69
+ if (match?.[1]) return match[1]
70
+ }
71
+ return /\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?/u.exec(text)?.[0]
72
+ }
73
+
74
+ export type Which = (command: string) => string | undefined
75
+
76
+ /** Look a command up on PATH the way a shell would, without running a shell. */
77
+ export function findExecutable(
78
+ command: string,
79
+ pathEnv: string = process.env.PATH ?? "",
80
+ isExecutable: (path: string) => boolean = canExecute,
81
+ ): string | undefined {
82
+ if (command.includes("/")) return isAbsolute(command) && isExecutable(command) ? command : undefined
83
+ for (const dir of pathEnv.split(delimiter)) {
84
+ if (dir === "") continue
85
+ const candidate = join(dir, command)
86
+ if (isExecutable(candidate)) return candidate
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ function canExecute(path: string): boolean {
92
+ try {
93
+ accessSync(path, constants.X_OK)
94
+ return true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ export type Exec = (command: string, args: readonly string[], options?: ExecOptions) => Promise<ExecResult>
101
+
102
+ export interface DetectToolDeps {
103
+ run?: Exec
104
+ which?: Which
105
+ timeoutMs?: number
106
+ }
107
+
108
+ /**
109
+ * Detect one tool: resolve it on PATH, then ask for its version with a hard
110
+ * timeout. Never throws - every failure becomes diagnostic state.
111
+ */
112
+ export async function detectTool(definition: ToolDefinition, deps: DetectToolDeps = {}): Promise<ToolStatus> {
113
+ const which = deps.which ?? ((command: string) => findExecutable(command))
114
+ const run = deps.run ?? exec
115
+ const timeoutMs = deps.timeoutMs ?? TOOL_TIMEOUT_MS
116
+ const base = { id: definition.id, name: definition.name }
117
+
118
+ const path =
119
+ which(definition.command) ?? definition.fallbackPaths?.find((candidate) => which(candidate) !== undefined)
120
+ if (!path) return { ...base, installed: false, message: "not installed" }
121
+
122
+ let result: ExecResult
123
+ try {
124
+ result = await run(path, definition.versionArgs ?? ["--version"], { timeoutMs })
125
+ } catch (error) {
126
+ return { ...base, installed: true, path, status: "error", message: String(error) }
127
+ }
128
+
129
+ if (result.timedOut) {
130
+ return { ...base, installed: true, path, status: "warning", message: `no answer within ${timeoutMs}ms` }
131
+ }
132
+ if (result.spawnError) {
133
+ return { ...base, installed: true, path, status: "error", message: result.spawnError.message }
134
+ }
135
+ if (!result.ok) {
136
+ const reason =
137
+ firstLine(result.stderr) ?? firstLine(result.stdout) ?? `exited with code ${result.code ?? "?"}`
138
+ return { ...base, installed: true, path, status: "error", message: reason }
139
+ }
140
+
141
+ const version = parseVersion(result.stdout || result.stderr, definition.versionPattern)
142
+ return { ...base, installed: true, path, version, status: "ready" }
143
+ }
144
+
145
+ export function firstLine(text: string): string | undefined {
146
+ const line = text
147
+ .split("\n")
148
+ .map((entry) => entry.trim())
149
+ .find((entry) => entry !== "")
150
+ return line
151
+ }
@@ -1,5 +1,7 @@
1
1
  import { TypedEmitter } from "../core/events.js"
2
2
  import { DevccError } from "../utils/errors.js"
3
+ import { existsSync, realpathSync } from "node:fs"
4
+ import { resolve } from "node:path"
3
5
  import { exec, type ExecResult } from "../utils/exec.js"
4
6
  import type { ScopedLogger } from "../utils/logger.js"
5
7
  import {
@@ -10,12 +12,24 @@ import {
10
12
  type GitCommit,
11
13
  type GitStatus,
12
14
  } from "./parse.js"
15
+ import {
16
+ parseWorktreeList,
17
+ removalBlocker,
18
+ validateBranchName,
19
+ validateRef,
20
+ validateWorktreePath,
21
+ worktreeAddArgs,
22
+ worktreeRemoveArgs,
23
+ type GitWorktree,
24
+ } from "./worktrees.js"
13
25
 
14
26
  export interface GitSnapshot {
15
27
  available: boolean
16
28
  isRepository: boolean
17
29
  status: GitStatus
18
30
  commits: GitCommit[]
31
+ /** Every worktree of the repository, current one flagged. Empty outside a repo. */
32
+ worktrees: GitWorktree[]
19
33
  version: string | null
20
34
  lastRefreshedAt: Date | null
21
35
  /** Set when the last refresh failed; the previous snapshot is kept. */
@@ -27,11 +41,20 @@ export const EMPTY_GIT_SNAPSHOT: GitSnapshot = {
27
41
  isRepository: false,
28
42
  status: EMPTY_GIT_STATUS,
29
43
  commits: [],
44
+ worktrees: [],
30
45
  version: null,
31
46
  lastRefreshedAt: null,
32
47
  error: null,
33
48
  }
34
49
 
50
+ export interface CreateWorktreeInput {
51
+ branch: string
52
+ /** Start point when the branch does not exist yet (default: HEAD). */
53
+ base?: string
54
+ /** Absolute destination directory. */
55
+ path: string
56
+ }
57
+
35
58
  export type GitServiceEvents = {
36
59
  "git:changed": [GitSnapshot]
37
60
  "git:error": [DevccError]
@@ -77,10 +100,11 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
77
100
  }
78
101
 
79
102
  private async doRefresh(): Promise<GitSnapshot> {
80
- const [versionResult, statusResult, logResult] = await Promise.all([
103
+ const [versionResult, statusResult, logResult, worktreeResult] = await Promise.all([
81
104
  this.git(["--version"]),
82
105
  this.git(["status", "--porcelain=v2", "--branch", "--untracked-files=all"]),
83
106
  this.git(["log", `--max-count=${this.commitLimit}`, `--pretty=format:${LOG_FORMAT}`]),
107
+ this.git(["worktree", "list", "--porcelain"]),
84
108
  ])
85
109
 
86
110
  const available = versionResult.spawnError === undefined
@@ -103,6 +127,7 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
103
127
  isRepository: false,
104
128
  status: EMPTY_GIT_STATUS,
105
129
  commits: [],
130
+ worktrees: [],
106
131
  version,
107
132
  lastRefreshedAt: new Date(),
108
133
  error: notARepo ? null : statusResult.stderr.trim() || "git status failed",
@@ -115,6 +140,7 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
115
140
  status: parsePorcelainV2(statusResult.stdout),
116
141
  // A fresh repository with no commits fails `git log`; an empty list is correct there.
117
142
  commits: logResult.ok ? parseCommits(logResult.stdout) : [],
143
+ worktrees: worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout, this.currentPath()) : [],
118
144
  version,
119
145
  lastRefreshedAt: new Date(),
120
146
  error: null,
@@ -162,6 +188,66 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
162
188
  await this.mutate(["commit", "--message", trimmed], "git commit")
163
189
  }
164
190
 
191
+ /** Fresh worktree list, independent of the polling snapshot. */
192
+ async listWorktrees(): Promise<GitWorktree[]> {
193
+ const result = await this.git(["worktree", "list", "--porcelain"])
194
+ if (!result.ok) {
195
+ throw new DevccError({
196
+ scope: "git",
197
+ message: "git worktree list failed",
198
+ detail: result.stderr.trim() || (result.spawnError ? "git executable not found" : undefined),
199
+ })
200
+ }
201
+ return parseWorktreeList(result.stdout, this.currentPath())
202
+ }
203
+
204
+ /**
205
+ * Create a worktree. An existing local branch is checked out; otherwise the
206
+ * branch is created from `base` (default HEAD). Input is validated before git
207
+ * runs, and git's own ref check has the final word on the branch name.
208
+ */
209
+ async createWorktree(input: CreateWorktreeInput): Promise<string> {
210
+ const branch = input.branch.trim()
211
+ const base = input.base?.trim() || undefined
212
+ const path = resolve(input.path.trim())
213
+
214
+ const problem =
215
+ validateBranchName(branch) ??
216
+ (base ? validateRef(base) : null) ??
217
+ validateWorktreePath(path, existsSync)
218
+ if (problem) throw new DevccError({ scope: "git", message: problem })
219
+
220
+ const refCheck = await this.git(["check-ref-format", "--branch", branch])
221
+ if (!refCheck.ok) {
222
+ throw new DevccError({ scope: "git", message: `"${branch}" is not a valid branch name` })
223
+ }
224
+
225
+ const existing = await this.git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])
226
+ const createBranch = !existing.ok
227
+ await this.mutate(
228
+ worktreeAddArgs({ path, branch, base: createBranch ? base : undefined, createBranch }),
229
+ "git worktree add",
230
+ 60_000,
231
+ )
232
+ return path
233
+ }
234
+
235
+ /**
236
+ * Remove a worktree without `--force`. devcc refuses the main and current
237
+ * worktrees and ones with running work (`busy`); git refuses dirty ones, and
238
+ * that refusal is surfaced verbatim rather than overridden.
239
+ */
240
+ async removeWorktree(path: string, busy: readonly string[] = []): Promise<void> {
241
+ const target = (await this.listWorktrees()).find((worktree) => worktree.path === path)
242
+ if (!target)
243
+ throw new DevccError({ scope: "git", message: `${path} is not a worktree of this repository` })
244
+
245
+ const blocker = removalBlocker(target, busy)
246
+ if (blocker) throw new DevccError({ scope: "git", message: blocker })
247
+
248
+ await this.mutate(worktreeRemoveArgs(target.path), "git worktree remove")
249
+ }
250
+
165
251
  /** Read-only diff for the inspect panel. */
166
252
  async diff(path: string, staged: boolean): Promise<string> {
167
253
  const args = ["--no-pager", "diff", "--no-color"]
@@ -176,9 +262,15 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
176
262
  if (!result.ok) {
177
263
  const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
178
264
  this.options.logger.warn(`${label} failed`, { code: result.code, detail })
265
+ // Prefer git's fatal/error line: progress chatter ("Preparing worktree…") can come first.
266
+ const lines = detail.split("\n")
267
+ const reason = (lines.find((line) => /^(fatal|error): /u.test(line)) ?? lines[0])?.replace(
268
+ /^(fatal|error): /u,
269
+ "",
270
+ )
179
271
  const error = new DevccError({
180
272
  scope: "git",
181
- message: `${label} failed`,
273
+ message: reason ? `${label} failed: ${reason}` : `${label} failed`,
182
274
  detail: detail || (result.timedOut ? "timed out" : undefined),
183
275
  })
184
276
  this.emit("git:error", error)
@@ -188,10 +280,20 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
188
280
  await this.refresh()
189
281
  }
190
282
 
283
+ /** Real path of the project root, so git's resolved paths compare equal (macOS /var -> /private/var). */
284
+ private currentPath(): string {
285
+ try {
286
+ return realpathSync(this.options.rootDir)
287
+ } catch {
288
+ return resolve(this.options.rootDir)
289
+ }
290
+ }
291
+
191
292
  private git(args: readonly string[], timeoutMs?: number): Promise<ExecResult> {
192
293
  return exec("git", args, {
193
294
  cwd: this.options.rootDir,
194
295
  timeoutMs: timeoutMs ?? this.timeoutMs,
296
+ logger: this.options.logger,
195
297
  // Never let git open an editor or a credential prompt under the TUI.
196
298
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_EDITOR: "true" },
197
299
  })