@norman-else/dsh-claude 0.1.39 → 0.1.40

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/lib/index.mjs CHANGED
@@ -4164,6 +4164,22 @@ function parseWorktreeBranches(value) {
4164
4164
  function slug(value, fallback) {
4165
4165
  return value.toLocaleLowerCase("en-US").replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 48) || fallback;
4166
4166
  }
4167
+ /** A branch name as one path segment: `/` and anything else unsafe folds to
4168
+ * `-`, case is kept so a ticket key stays scannable (`PSOS-5683`, not
4169
+ * `psos-5683`). Flattened rather than nested on purpose -- see
4170
+ * {@link worktreeDirectoryName}. */
4171
+ function branchSegment(branch) {
4172
+ return branch.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^[-.]+/u, "").replace(/-+$/u, "").slice(0, 48).replace(/-+$/u, "") || "branch";
4173
+ }
4174
+ /** Name a worktree directory after the repository and the branch it holds, so
4175
+ * the workspace list reads without opening a session.
4176
+ *
4177
+ * One flat segment, never a `feature/x` subdirectory: the orphan sweep lists
4178
+ * this root one level deep, and a prefix directory holds no lease of its own,
4179
+ * so it would be removed along with every live worktree inside it. */
4180
+ function worktreeDirectoryName(root, branch) {
4181
+ return `${slug(basename(root), "repository")}-${branchSegment(branch)}`;
4182
+ }
4167
4183
  /** Comparable form for path identity: resolved, forward slashes, case-folded
4168
4184
  * so Windows drive-letter or case spelling differences cannot hide a match. */
4169
4185
  function comparablePath(value) {
@@ -4463,6 +4479,18 @@ var RepositorySetupService = class {
4463
4479
  ], root, GIT_FETCH_TIMEOUT_MS);
4464
4480
  if (fetched.exitCode !== 0 || fetched.lossy) throw new RepositorySetupError("fetch-failed", "Git could not refresh remote references.");
4465
4481
  }
4482
+ /** The remote-tracking ref a local base branch follows, when that ref still
4483
+ * exists. Falls back to the local branch for a branch with no upstream. */
4484
+ async #upstreamRef(git, info, branch) {
4485
+ const upstream = await this.#run(git, [
4486
+ "for-each-ref",
4487
+ "--format=%(upstream:short)",
4488
+ `refs/heads/${branch}`
4489
+ ], info.root);
4490
+ if (upstream.exitCode !== 0 || upstream.lossy) return void 0;
4491
+ const name = upstream.stdout.trim();
4492
+ return name.length > 0 && info.remoteBranches.includes(name) ? `refs/remotes/${name}` : void 0;
4493
+ }
4466
4494
  async #checkout(info, branch) {
4467
4495
  if (info.current !== branch) {
4468
4496
  if (info.dirty) throw new RepositorySetupError("dirty-workspace", "Commit or stash workspace changes before switching branches.");
@@ -4493,10 +4521,11 @@ var RepositorySetupService = class {
4493
4521
  const git = await this.#git();
4494
4522
  progress("fetching");
4495
4523
  await this.#fetchRemotes(git, root);
4524
+ const startRef = reuseExistingBranch ? baseRef : await this.#upstreamRef(git, info, baseBranch) ?? baseRef;
4496
4525
  const suffix = randomUUID().slice(0, 8);
4497
4526
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
4498
4527
  const branch = explicitBranchName ?? await this.#generatedBranch(info, baseBranch, intent, stamp, suffix, progress);
4499
- const path = join(this.#worktreeRoot, `${slug(basename(root), "repository")}-${stamp}-${suffix}`);
4528
+ const path = join(this.#worktreeRoot, await this.#freeDirectoryName(root, branch));
4500
4529
  progress("creating-worktree");
4501
4530
  await mkdir(this.#worktreeRoot, { recursive: true });
4502
4531
  if (reuseExistingBranch) await this.#run(git, ["worktree", "prune"], root).catch(() => void 0);
@@ -4512,7 +4541,7 @@ var RepositorySetupService = class {
4512
4541
  "-b",
4513
4542
  branch,
4514
4543
  path,
4515
- baseRef
4544
+ startRef
4516
4545
  ], root);
4517
4546
  if (created.exitCode !== 0) {
4518
4547
  const detail = created.stderr.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0).at(-1);
@@ -4559,6 +4588,21 @@ var RepositorySetupService = class {
4559
4588
  /** `<prefix>/<what the draft is about>`, falling back to
4560
4589
  * `<prefix>/<base branch>-<stamp>-<random>` whenever the summary is missing
4561
4590
  * or unusable. The prefix and the fallback shape are unchanged. */
4591
+ /** The branch-named directory, or the first `-2`, `-3`, ... spelling free of
4592
+ * anything already on disk. Names no longer carry a timestamp, so a stale
4593
+ * directory a crash left behind -- or two branches that fold to the same
4594
+ * segment -- would otherwise fail `worktree add` outright. Compared
4595
+ * case-folded, since a case-insensitive filesystem would collide anyway. */
4596
+ async #freeDirectoryName(root, branch) {
4597
+ const base = worktreeDirectoryName(root, branch);
4598
+ const taken = await readdir(this.#worktreeRoot).then((entries) => new Set(entries.map((entry) => entry.toLocaleLowerCase("en-US"))), () => /* @__PURE__ */ new Set());
4599
+ if (!taken.has(base.toLocaleLowerCase("en-US"))) return base;
4600
+ for (let index = 2; index < 100; index += 1) {
4601
+ const candidate = `${base}-${index}`;
4602
+ if (!taken.has(candidate.toLocaleLowerCase("en-US"))) return candidate;
4603
+ }
4604
+ return base;
4605
+ }
4562
4606
  async #generatedBranch(info, baseBranch, intent, stamp, suffix, progress) {
4563
4607
  const prefix = safeBranch(await this.#branchPrefix());
4564
4608
  if (intent !== void 0 && intent.trim().length > 0) {