@brimveyn/aimux 1.22.3 → 1.22.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.3",
3
+ "version": "1.22.5",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -8,7 +8,7 @@ import { loadConfig, saveConfig } from '../config'
8
8
  import { logInputDebug } from '../debug/input-log'
9
9
  import { enqueueGitOp } from '../git/command-queue'
10
10
  import { countDirtyFiles } from '../git/move-workspace'
11
- import { getDefaultBranch, listLocalBranches } from '../git/worktree'
11
+ import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
12
12
  import { assistantAcceptsPromptArg } from '../pty/command-registry'
13
13
  import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
14
14
  import { saveCurrentProject } from '../state/project-save'
@@ -72,6 +72,7 @@ import {
72
72
  isForceableWorkspaceDeleteError,
73
73
  runDeleteWorkspace,
74
74
  runMoveWorkspace,
75
+ setProjectDefaultBaseRef,
75
76
  } from './workspace-actions'
76
77
  import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
77
78
 
@@ -207,6 +208,12 @@ function applyThemeEffect(
207
208
  }
208
209
  }
209
210
 
211
+ /** The given branch first, the rest in the order they came. */
212
+ function hoistBranch(branches: string[], first: string | undefined): string[] {
213
+ if (first == null || !branches.includes(first)) return branches
214
+ return [first, ...branches.filter((branch) => branch !== first)]
215
+ }
216
+
210
217
  /**
211
218
  * Setup runs concurrently with the agent by design, so say so rather than
212
219
  * letting the agent run tests against a half-installed tree and draw the wrong
@@ -359,12 +366,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
359
366
  const project = state.projects.find((entry) => entry.id === state.currentProjectId)
360
367
  const sourcePath = getActiveWorkspace(project)?.path ?? getActiveWorkspacePath(project)
361
368
  if (!(sourcePath != null && sourcePath !== '')) return
362
- const [branches, defaultBranch] = await Promise.all([
369
+ const [branches, defaultBranch, currentBranch] = await Promise.all([
363
370
  listLocalBranches(sourcePath),
364
371
  getDefaultBranch(sourcePath),
372
+ getCurrentBranch(sourcePath),
365
373
  ])
366
374
  if (ctx.getState().modal.type !== 'create-workspace') return
367
- ctx.dispatch({ branches, defaultBranch, type: 'set-create-workspace-base-branches' })
375
+ ctx.dispatch({
376
+ // Stacking on the branch you are already on is the other common base,
377
+ // and committer date buries it once anyone else pushes.
378
+ branches: hoistBranch(branches, currentBranch),
379
+ // The project's convention wins over what the repo declares: a gitflow
380
+ // repo still says `main` while everyone branches off `develop`.
381
+ defaultBranch: project?.defaultBaseRef ?? defaultBranch,
382
+ type: 'set-create-workspace-base-branches',
383
+ })
368
384
  })()
369
385
  return
370
386
  }
@@ -678,6 +694,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
678
694
  handleConfigureSetupScriptEffect(ctx, effect.projectId)
679
695
  return
680
696
  }
697
+ case 'set-project-default-base-ref': {
698
+ setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
699
+ return
700
+ }
681
701
  case 'ask-agent-for-setup-script': {
682
702
  handleAskAgentForSetupScriptEffect(ctx)
683
703
  return
@@ -729,11 +749,18 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
729
749
  void ctx.backend.destroy(true)
730
750
  ctx.renderer.destroy()
731
751
  process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
732
- const proc = Bun.spawn(['bun', 'update', '-g', '@brimveyn/aimux', '@brimveyn/aimux-config'], {
733
- stderr: 'inherit',
734
- stdin: 'inherit',
735
- stdout: 'inherit',
736
- })
752
+ // `bun update -g` is a no-op when the global entry pins an exact version
753
+ // (`"@brimveyn/aimux": "1.22.2"`), which is what `install -g pkg@version`
754
+ // writes — the update "succeeded", the version never moved, and the modal
755
+ // came back every launch. Install the resolved version explicitly instead.
756
+ const proc = Bun.spawn(
757
+ ['bun', 'install', '-g', `@brimveyn/aimux@${latestVersion}`, '@brimveyn/aimux-config@latest'],
758
+ {
759
+ stderr: 'inherit',
760
+ stdin: 'inherit',
761
+ stdout: 'inherit',
762
+ }
763
+ )
737
764
  void (async () => {
738
765
  const code = await proc.exited
739
766
  if (code === 0) {
@@ -23,6 +23,7 @@ import {
23
23
  makeWorktreePath,
24
24
  sanitizePathSegment,
25
25
  } from '../platform/worktree-paths'
26
+ import { shouldRefreshBase } from '../settings/flags'
26
27
  import { saveProjectCatalog } from '../state/project-catalog'
27
28
  import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
28
29
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -64,6 +65,27 @@ export function handleSwitchWorkspace(
64
65
  ctx.dispatch({ projects, type: 'set-projects' })
65
66
  }
66
67
 
68
+ /**
69
+ * Set (or clear, when blank) the branch this project's new workspaces fork from.
70
+ * Here rather than with the project actions: the value is workspace policy that
71
+ * happens to be stored on the project, and this is where the record-update
72
+ * helper and the rest of that policy already live.
73
+ */
74
+ export function setProjectDefaultBaseRef(
75
+ ctx: SideEffectContext,
76
+ projectId: string,
77
+ baseRef: string
78
+ ): void {
79
+ const trimmed = baseRef.trim()
80
+ const projects = replaceProject(ctx, projectId, (entry) => ({
81
+ ...entry,
82
+ defaultBaseRef: trimmed === '' ? undefined : trimmed,
83
+ updatedAt: new Date().toISOString(),
84
+ }))
85
+ saveProjectCatalog(projects)
86
+ ctx.dispatch({ projects, type: 'set-projects' })
87
+ }
88
+
67
89
  function normalizeBranchName(branch: string | undefined): string | undefined {
68
90
  return branch?.replace(/^refs\/heads\//, '').trim()
69
91
  }
@@ -116,11 +138,19 @@ export async function createAimuxTempWorkspace(
116
138
 
117
139
  await mkdir(dirname(targetPath), { recursive: true })
118
140
  await assertSafeAimuxWorktreePath(targetPath)
119
- await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
141
+ // What it forked from, not what was asked for: the two differ whenever the
142
+ // base was refreshed from origin, and the record is what "based on" reads.
143
+ const forkRef = await createGitWorktree({
144
+ baseRef,
145
+ branchName,
146
+ refreshBase: shouldRefreshBase(),
147
+ repoPath: repoRoot,
148
+ targetPath,
149
+ })
120
150
  const now = new Date().toISOString()
121
151
 
122
152
  const workspace: WorkspaceRecord = {
123
- baseRef,
153
+ baseRef: forkRef,
124
154
  branch: branchName,
125
155
  commitSha: await getHeadSha(targetPath),
126
156
  createdAt: now,
@@ -10,6 +10,7 @@ import {
10
10
  makeWorktreePath,
11
11
  pruneEmptyWorktreeParent,
12
12
  } from '../../../platform/worktree-paths'
13
+ import { shouldRefreshBase } from '../../../settings/flags'
13
14
 
14
15
  export interface CreateWorkspaceParams {
15
16
  /** Base ref for the branch (callers default to 'HEAD'). */
@@ -68,10 +69,12 @@ export async function createProjectWorkspace(
68
69
  await ensureAimuxWorktreeRoot()
69
70
  await assertSafeAimuxWorktreePath(targetPath)
70
71
 
72
+ let forkRef: string
71
73
  try {
72
- await createGitWorktree({
74
+ forkRef = await createGitWorktree({
73
75
  baseRef: base,
74
76
  branchName: branch,
77
+ refreshBase: shouldRefreshBase(),
75
78
  repoPath: primary.repoRoot,
76
79
  targetPath,
77
80
  })
@@ -84,7 +87,8 @@ export async function createProjectWorkspace(
84
87
 
85
88
  const now = new Date().toISOString()
86
89
  const record: WorkspaceRecord = {
87
- baseRef: base,
90
+ // What it forked from: `origin/<base>` when the base was refreshed.
91
+ baseRef: forkRef,
88
92
  branch,
89
93
  createdAt: now,
90
94
  createdByAimux: true,
@@ -98,9 +98,48 @@ export async function renameGitBranch(
98
98
  return result.exitCode === 0
99
99
  }
100
100
 
101
+ /**
102
+ * The ref a new workspace should actually fork from.
103
+ *
104
+ * "Base it on main" means the main the team pushed, not whatever the local main
105
+ * happened to be at the last pull — a workspace started ten commits behind says
106
+ * nothing at the time and shows up as conflicts at merge. So fetch the base's
107
+ * counterpart on origin and fork from that when there is one.
108
+ *
109
+ * Every step is best-effort on purpose. The fetch is `nothrow`, so a machine
110
+ * with no network still gets a workspace off the local ref; and a base with no
111
+ * counterpart on origin — a local-only branch, a SHA, `HEAD`, or an
112
+ * already-qualified `origin/x` — resolves to nothing and is used as given, which
113
+ * is why no ref-shape guessing is needed here.
114
+ *
115
+ * The refspec is spelled out rather than left to `remote.origin.fetch`: a bare
116
+ * `fetch origin main` on a remote with no configured refspec updates only
117
+ * FETCH_HEAD, leaving a stale `origin/main` that resolves perfectly well — so
118
+ * the fork would silently land on exactly the old commit this exists to avoid.
119
+ *
120
+ * ponytail: unpushed commits on the local base are unreachable from the picker
121
+ * once this is on — the remote ref wins whenever it exists, and the toggle is
122
+ * the only way back. Listing `main` and `origin/main` as separate base options
123
+ * is the upgrade path.
124
+ */
125
+ export async function resolveWorktreeBaseRef(repoPath: string, baseRef: string): Promise<string> {
126
+ const remoteRef = `origin/${baseRef}`
127
+ await $`git -C ${repoPath} fetch origin +${baseRef}:refs/remotes/${remoteRef}`.quiet().nothrow()
128
+ return (await resolveGitRef(repoPath, remoteRef)) !== undefined ? remoteRef : baseRef
129
+ }
130
+
131
+ /**
132
+ * Returns the ref it forked from, which is not always the one it was handed.
133
+ *
134
+ * `refreshBase` is required rather than read from the user's config here: this
135
+ * module wraps git and nothing else, and a caller that has to name the policy
136
+ * cannot forget it exists — which a settings lookup hidden in here would let
137
+ * them do. `shouldRefreshBase` is what both callers name it with.
138
+ */
101
139
  export async function createGitWorktree({
102
140
  baseRef,
103
141
  branchName,
142
+ refreshBase,
104
143
  repoPath,
105
144
  targetPath,
106
145
  }: {
@@ -108,10 +147,18 @@ export async function createGitWorktree({
108
147
  targetPath: string
109
148
  branchName: string
110
149
  baseRef: string
111
- }): Promise<void> {
112
- const result = await $`git -C ${repoPath} worktree add -b ${branchName} ${targetPath} ${baseRef}`
113
- .quiet()
114
- .nothrow()
150
+ refreshBase: boolean
151
+ }): Promise<string> {
152
+ const forkRef = refreshBase ? await resolveWorktreeBaseRef(repoPath, baseRef) : baseRef
153
+ // `--no-track`: forking from `origin/main` would otherwise set that as the new
154
+ // branch's upstream, and a workspace branch tracking main is not a workspace
155
+ // branch — `git push` either refuses it (push.default=simple) or, worse,
156
+ // pushes the branch onto main. Forking from a local branch never set tracking,
157
+ // so this only pins down what refreshing the base would have changed.
158
+ const result =
159
+ await $`git -C ${repoPath} worktree add --no-track -b ${branchName} ${targetPath} ${forkRef}`
160
+ .quiet()
161
+ .nothrow()
115
162
  if (result.exitCode !== 0) {
116
163
  // Always name the repository. A bare "fatal: not a valid object name: 'X'"
117
164
  // reads as a ref-resolution bug when the real cause is that git ran in a
@@ -119,6 +166,7 @@ export async function createGitWorktree({
119
166
  const stderr = result.stderr.toString().trim()
120
167
  throw new Error(`${stderr || 'failed to create git worktree'} (in ${repoPath})`)
121
168
  }
169
+ return forkRef
122
170
  }
123
171
 
124
172
  // Force-delete a local branch. Returns false (without throwing) when git
@@ -158,7 +206,7 @@ export async function removeGitWorktree({
158
206
  force: boolean
159
207
  }): Promise<void> {
160
208
  if (!isInsideAimuxWorktreeRoot(targetPath)) {
161
- throw new Error(`refusing to delete worktree outside Aimux temp root: ${targetPath}`)
209
+ throw new Error(`refusing to delete worktree outside Aimux worktree root: ${targetPath}`)
162
210
  }
163
211
  const result = force
164
212
  ? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
@@ -114,6 +114,7 @@ export type SideEffect =
114
114
  | { type: 'run-setup' }
115
115
  | { type: 'stop-setup' }
116
116
  | { type: 'configure-setup-script'; projectId?: string }
117
+ | { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
117
118
  | { type: 'ask-agent-for-setup-script' }
118
119
  | { type: 'promote-setup-tab' }
119
120
  /** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
@@ -141,6 +141,7 @@ function findPlayer(platform: string): string | null {
141
141
  }
142
142
 
143
143
  let lastPlayedAt = 0
144
+ let playing: { kill: () => void } | null = null
144
145
 
145
146
  /**
146
147
  * Whether enough time has passed since the last sound. Separate from the play
@@ -151,16 +152,17 @@ export function shouldPlayNow(now: number, previous: number): boolean {
151
152
  }
152
153
 
153
154
  /**
154
- * Play a sound file. Returns whether a player was actually spawned, which is
155
- * what the settings screen's "Test sound" row reports.
155
+ * Play a sound file. Returns false only when nothing could play at all — a
156
+ * missing player or a failed spawn — which is what the settings screen's "Test
157
+ * sound" row reports. A throttled call is not a failure.
158
+ *
159
+ * The throttle covers every caller, the test row included: held down, its key
160
+ * repeats tens of times a second, and one live player per press is enough to
161
+ * take CoreAudio down with it.
156
162
  */
157
- export function playSoundFile(
158
- path: string,
159
- options?: { ignoreThrottle?: boolean; volume?: number }
160
- ): boolean {
163
+ export function playSoundFile(path: string, options?: { volume?: number }): boolean {
161
164
  const now = Date.now()
162
- const throttled = options?.ignoreThrottle !== true
163
- if (throttled && !shouldPlayNow(now, lastPlayedAt)) return false
165
+ if (!shouldPlayNow(now, lastPlayedAt)) return true
164
166
  const platform = process.platform
165
167
  const bin = findPlayer(platform)
166
168
  if (bin == null) {
@@ -169,10 +171,11 @@ export function playSoundFile(
169
171
  }
170
172
  const argv = soundPlayerArgv(platform, bin, path, options?.volume ?? DEFAULT_VOLUME)
171
173
  try {
172
- Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
173
- // A test press does not start the window: it would swallow a real
174
- // notification arriving in the next few hundred milliseconds.
175
- if (throttled) lastPlayedAt = now
174
+ // Cut the previous one rather than layer on top of it. Players come and go
175
+ // on their own, so killing an already-exited one has to be harmless.
176
+ playing?.kill()
177
+ playing = Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
178
+ lastPlayedAt = now
176
179
  return true
177
180
  } catch (error) {
178
181
  logDebug('platform.playSound.error', {
@@ -2,12 +2,23 @@ import { createHash } from 'node:crypto'
2
2
  import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
4
 
5
- const DEFAULT_WORKTREE_ROOT = '/tmp/aimux-wt'
5
+ // Worktrees hold uncommitted work, so they live in the XDG data dir, not /tmp:
6
+ // a reboot clears /tmp on macOS and on most Linux distros, and took the work
7
+ // with it. The old root stays recognized (never generated) so worktrees created
8
+ // before this change are still classified and deleted as aimux-managed.
9
+ const LEGACY_WORKTREE_ROOT = '/tmp/aimux-wt'
6
10
  const MAX_SLUG_LENGTH = 24
7
11
 
12
+ function defaultWorktreeRoot(): string {
13
+ const xdgData = process.env.XDG_DATA_HOME
14
+ const base =
15
+ xdgData != null && xdgData !== '' ? xdgData : join(process.env.HOME ?? '.', '.local', 'share')
16
+ return join(base, 'aimux', 'worktrees')
17
+ }
18
+
8
19
  export function getAimuxWorktreeRoot(): string {
9
20
  const root = process.env.AIMUX_WORKTREE_ROOT
10
- return root != null && root !== '' ? root : DEFAULT_WORKTREE_ROOT
21
+ return root != null && root !== '' ? root : defaultWorktreeRoot()
11
22
  }
12
23
 
13
24
  export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
@@ -42,9 +53,10 @@ export function makeWorktreePath({
42
53
 
43
54
  export function isInsideAimuxWorktreeRoot(path: string): boolean {
44
55
  const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
45
- const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
46
56
  const target = `${normalizeTmp(resolve(path))}/`
47
- return target.startsWith(root)
57
+ return [getAimuxWorktreeRoot(), LEGACY_WORKTREE_ROOT].some((root) =>
58
+ target.startsWith(`${normalizeTmp(resolve(root))}/`)
59
+ )
48
60
  }
49
61
 
50
62
  export async function ensureAimuxWorktreeRoot(): Promise<string> {
@@ -80,13 +92,13 @@ export async function pruneEmptyWorktreeParent(worktreePath: string): Promise<vo
80
92
  export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
81
93
  const root = await ensureAimuxWorktreeRoot()
82
94
  if (!isInsideAimuxWorktreeRoot(path)) {
83
- throw new Error(`refusing worktree path outside Aimux temp root: ${path}`)
95
+ throw new Error(`refusing worktree path outside Aimux worktree root: ${path}`)
84
96
  }
85
97
  // Create the repo-scoped parent (<root>/r-<hash>) before resolving it: git
86
98
  // worktree add does not create intermediate dirs, and realpath() would throw
87
99
  // ENOENT on the first worktree for a repo. mkdir(recursive) leaves an
88
100
  // existing symlink in place, so the realpath check below still catches an
89
- // escape out of the temp root.
101
+ // escape out of the worktree root.
90
102
  const parent = resolve(path, '..')
91
103
  await mkdir(parent, { recursive: true })
92
104
  const realRoot = await realpath(root)
@@ -0,0 +1,15 @@
1
+ import { loadConfig } from '../config'
2
+ import { FETCH_BASE } from './sections/git'
3
+
4
+ /**
5
+ * Settings read outside the screen, off the file rather than the store.
6
+ *
7
+ * `live.ts` is the same read side for the running TUI, through hooks over the
8
+ * hydrated store. These have to answer where there is no store: the CLI never
9
+ * hydrates one, and a setting only the TUI honours is a setting that lies.
10
+ */
11
+
12
+ /** Whether a new workspace forks from the base as pushed, or as last pulled. */
13
+ export function shouldRefreshBase(): boolean {
14
+ return loadConfig().settings?.[FETCH_BASE] !== false
15
+ }
@@ -5,6 +5,8 @@ import type { SettingSection, SettingValue } from '../types'
5
5
 
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
7
 
8
+ export const FETCH_BASE = 'git.fetchBase'
9
+
8
10
  function isFileListMode(value: SettingValue): value is GitFileListMode {
9
11
  return value === 'tree' || value === 'flat'
10
12
  }
@@ -81,6 +83,16 @@ export const GIT_SECTION: SettingSection = {
81
83
  step: 1,
82
84
  storage: 'settings',
83
85
  },
86
+ {
87
+ // No `apply`: `shouldRefreshBase` reads this off the file, so the CLI —
88
+ // where this screen never hydrates — honours it too.
89
+ description: 'Fetch the base branch before forking from it. Off forks from your local copy.',
90
+ fallback: true,
91
+ id: FETCH_BASE,
92
+ kind: 'toggle',
93
+ label: 'Refresh the base branch',
94
+ storage: 'settings',
95
+ },
84
96
  {
85
97
  apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
86
98
  description: 'Aggregate the status of git repos nested under the project.',
@@ -13,6 +13,7 @@ import { LAYOUT_SECTION } from './layout'
13
13
  import { NOTIFICATIONS_SECTION } from './notifications'
14
14
  import { SETUP_SECTION } from './setup'
15
15
  import { STATUS_BAR_SECTION } from './status-bar'
16
+ import { WORKSPACE_SECTION } from './workspace'
16
17
 
17
18
  /**
18
19
  * Every section, in the order the screen lists them. Adding a setting is one
@@ -24,6 +25,7 @@ export const SETTING_SECTIONS: readonly SettingSection[] = [
24
25
  AUTOMATION_SECTION,
25
26
  NOTIFICATIONS_SECTION,
26
27
  SETUP_SECTION,
28
+ WORKSPACE_SECTION,
27
29
  GIT_SECTION,
28
30
  COMMANDS_SECTION,
29
31
  EDITOR_SECTION,
@@ -45,10 +45,10 @@ function selectedVolume(): number {
45
45
  * user has set the row to `off`, which is what makes it safe to call on every
46
46
  * status edge.
47
47
  */
48
- export function playNotificationSound(options?: { ignoreThrottle?: boolean }): boolean {
48
+ export function playNotificationSound(): boolean {
49
49
  const path = resolveSoundPath(selectedSoundId())
50
50
  if (path == null) return false
51
- return playSoundFile(path, { ...options, volume: selectedVolume() })
51
+ return playSoundFile(path, { volume: selectedVolume() })
52
52
  }
53
53
 
54
54
  export const NOTIFICATIONS_SECTION: SettingSection = {
@@ -89,7 +89,7 @@ export const NOTIFICATIONS_SECTION: SettingSection = {
89
89
  toast.info('Notification sound is off')
90
90
  return
91
91
  }
92
- if (!playNotificationSound({ ignoreThrottle: true })) {
92
+ if (!playNotificationSound()) {
93
93
  toast.error('Could not play that sound — no audio player found')
94
94
  }
95
95
  },
@@ -0,0 +1,44 @@
1
+ import type { ProjectRecord } from '../../state/types'
2
+ import type { SettingRow, SettingSection } from '../types'
3
+
4
+ import { runSideEffectGlobal } from '../../state/dispatch-ref'
5
+
6
+ const BASE_REF_ROW_PREFIX = 'workspace.defaultBaseRef.'
7
+
8
+ /**
9
+ * One row per project, over `defaultBaseRef` on its catalog record.
10
+ *
11
+ * Per project rather than global because the convention belongs to the repo:
12
+ * one clone branches off `develop`, the next off `main`, and a single value
13
+ * would be wrong for every repo but one. Left empty it follows whatever the
14
+ * repo declares as its default branch, which is the right answer most of the
15
+ * time and the reason this is a row and not a prompt.
16
+ */
17
+ function defaultBaseRefRow(project: ProjectRecord): SettingRow {
18
+ return {
19
+ description: "Branch new workspaces fork from. Empty follows the repo's own default.",
20
+ id: `${BASE_REF_ROW_PREFIX}${project.id}`,
21
+ kind: 'text',
22
+ label: project.name,
23
+ placeholder: "the repo's default",
24
+ // Read through the live state, not the record this row was built from: the
25
+ // screen rebuilds its rows from the same list it just wrote to.
26
+ read: (ctx) =>
27
+ ctx.state.projects.find((entry) => entry.id === project.id)?.defaultBaseRef ?? '',
28
+ storage: 'app',
29
+ write: (value) =>
30
+ runSideEffectGlobal({
31
+ baseRef: String(value),
32
+ projectId: project.id,
33
+ type: 'set-project-default-base-ref',
34
+ }),
35
+ }
36
+ }
37
+
38
+ export const WORKSPACE_SECTION: SettingSection = {
39
+ id: 'workspace',
40
+ label: 'Workspaces',
41
+ rowCount: (projects) => projects.length,
42
+ rows: (projects: readonly ProjectRecord[]) =>
43
+ projects.map((project) => defaultBaseRefRow(project)),
44
+ }
@@ -217,6 +217,14 @@ export interface ProjectRecord {
217
217
  projectSnapshot?: ProjectSnapshotV1
218
218
  workspaces?: WorkspaceRecord[]
219
219
  activeWorkspaceId?: string
220
+ /**
221
+ * The branch new workspaces fork from, when the repo's own default is the
222
+ * wrong answer — a gitflow repo branches off `develop` while still declaring
223
+ * `main` as its default, and nothing about the repo says so. Per project
224
+ * because the convention is the team's, not the user's: three repos, three
225
+ * answers. Unset means the repo's default branch.
226
+ */
227
+ defaultBaseRef?: string
220
228
  }
221
229
 
222
230
  export interface ProjectBarState {
@@ -180,7 +180,8 @@ export function isProjectRecord(value: unknown): value is ProjectRecord {
180
180
  (value.projectSnapshot === undefined || isProjectSnapshotV1(value.projectSnapshot)) &&
181
181
  (value.workspaces === undefined ||
182
182
  (Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceRecord))) &&
183
- (value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId))
183
+ (value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId)) &&
184
+ (value.defaultBaseRef === undefined || isString(value.defaultBaseRef))
184
185
  )
185
186
  }
186
187