@senad-d/branchme 0.3.13 → 0.3.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.14 - Unreleased
4
+
5
+ - A Git mutation that fails because another git process holds `index.lock`, `HEAD.lock` or a ref lock is retried every 250 ms for up to 10 seconds, then fails with git's error plus "The lock was still held after waiting 10 s."; lock files are never removed, and cancellation interrupts the wait without another mutation attempt. Local Git reads (status and the other snapshot reads) time out after 30 seconds instead of 5, for slow or emulated git under parallel agents.
3
6
  ## 0.3.13 - Unreleased
4
7
 
5
8
  - Added optional `integrate_branch.keepConflicts` (default `false`). When true, a conflicted local integration returns `conflict_kept` with the merge left in progress for `conclude_merge` to finish after resolution or abort; default conflict handling still aborts and verifies restoration. Updated schemas, prompt guidance, documentation, and real-Git regression coverage.
package/README.md CHANGED
@@ -303,7 +303,7 @@ Before each agent run, BranchMe appends an **Automatic Git Context** snapshot to
303
303
  - whether pull request field autofill is enabled;
304
304
  - up to 5 recent commits with short hash, date, and subject.
305
305
 
306
- Collection defaults are a 5-second timeout per local Git command, a 4-second related-PR lookup timeout, at most 512 characters per metadata value, and at most 4,000 characters for the rendered snapshot. GitHub response bodies are limited to 64 KiB. The formatter can further shorten values or omit entries to stay within the total limit.
306
+ Collection defaults are a 30-second timeout per local Git read, a 4-second related-PR lookup timeout, at most 512 characters per metadata value, and at most 4,000 characters for the rendered snapshot. GitHub response bodies are limited to 64 KiB. The formatter can further shorten values or omit entries to stay within the total limit.
307
307
 
308
308
  The snapshot is fresh at agent start but is not live. A fetch, branch switch, pull, rebase, integration, commit, file change, push, or other mutation later in the same run can make it stale. `branch_status` performs an explicit current-state refresh through the same shared collector and remains read-only; it does not mutate files, Git state, or GitHub state. Its optional `ancestry` object requires exact `sourceBranch` and `targetBranch` fields together, captures both commit IDs, and reports `isAncestor`. Each endpoint may be an exact local branch or a remote-tracking ref such as `origin/main` (local branches take precedence), so a fetched `origin/main` can serve as a read-only containment proof after a pull request merges. This targeted proof is explicit only: automatic Git context remains unchanged. Run it after `integrate_branch` or `fetch_branch` completes, never in the same parallel tool batch.
309
309
 
@@ -466,7 +466,7 @@ Repository-configured `reference-transaction` hooks can run during `git update-r
466
466
  BranchMe operates only on the repository where pi is running:
467
467
 
468
468
  - Automatic collection and `branch_status` run bounded, read-only Git commands from the verified git root.
469
- - Git commands use `pi.exec("git", args, { cwd, signal, timeout })` with argv arrays; repository mutations run from the verified git root.
469
+ - Git commands use `pi.exec("git", args, { cwd, signal, timeout })` with argv arrays; repository mutations run from the verified git root. A command that fails because another git process holds a lock (`index.lock`, `HEAD.lock`, a ref lock) is retried every 250 ms for up to 10 seconds, then fails with git's error plus a note that the lock was still held; BranchMe never removes a lock file.
470
470
  - `change_branch` switches only to existing local branches and has no `force`, `stash`, `discard`, remote, or path input.
471
471
  - `fetch_branch` uses an explicit source-to-remote-tracking refspec with tags and submodule recursion disabled and does not change local branches or working-tree files. Without arguments it requires a configured upstream; with an explicit `branch` (and optional configured `remote`, default `origin`) it refreshes only `refs/remotes/<remote>/<branch>` and never touches upstream configuration.
472
472
  - `pull_branch` requires a clean worktree and configured upstream, updates only the current branch with `git pull --ff-only --no-rebase --no-autostash`, and has no branch, remote, force, or rebase input.
package/docs/STRUCTURE.md CHANGED
@@ -55,7 +55,7 @@ src/
55
55
  - Slash commands are informational; tools perform repository initialization, branch, integration, retirement, worktree, push, and PR actions. Commands never initialize repositories, create/remove worktrees, merge or retire branches, change cwd, or start Pi sessions. There is no context command.
56
56
  - Every tool uses a strict TypeBox object schema with `additionalProperties: false`; `init_repository` accepts only optional `initialBranch`; `integrate_branch` requires exactly `sourceBranch` and `targetBranch` with optional boolean `keepConflicts`; `retire_branch` requires exactly `branchName`, full commit `expectedHead`, distinct `targetBranch`, and boolean `force`; `update_from_base` accepts optional boolean `keepConflicts`; `conclude_merge` requires exactly `action` (`conclude` or `abort`); worktree creation requires `worktreePath`, `branchName`, and `branchMode` with optional new-mode `baseRef`, listing is empty, and removal requires `worktreePath` with optional boolean `deleteIgnored`.
57
57
  - Every tool defines a description, `promptSnippet`, and tool-specific `promptGuidelines` that explicitly name the tool.
58
- - Git commands use argv arrays with cwd, signal, and timeout; network calls use the transport wrapper's absolute executable and child environment, while other commands use `pi.exec("git", args, { cwd, signal, timeout })`; `init_repository` uses Pi's file-mutation queue for the new `.git` entry and queues on the canonical target directory, and repository mutations run from verified locations and same-repository mutation/PR windows are serialized per repository. Retirement uses one canonical-active-worktree-keyed queue window from preflight through final verification. The queue is process-local and does not lock a different active worktree, another Pi process, or an external Git process.
58
+ - Git commands use argv arrays with cwd, signal, and timeout; network calls use the transport wrapper's absolute executable and child environment, while other commands use `pi.exec("git", args, { cwd, signal, timeout })`; `runGit` retries a command that failed on another process's `*.lock` every 250 ms for up to 10 s and never touches the lock; `init_repository` uses Pi's file-mutation queue for the new `.git` entry and queues on the canonical target directory, and repository mutations run from verified locations and same-repository mutation/PR windows are serialized per repository. Retirement uses one canonical-active-worktree-keyed queue window from preflight through final verification. The queue is process-local and does not lock a different active worktree, another Pi process, or an external Git process.
59
59
  - Worktree results expose serializable requested and verified before/after state. Create returns `handoff: { cwd: <absolute>, branch, head, ready: true, summary }`; remove returns the retained branch/HEAD with `cwd: null` and `ready: false`.
60
60
  - Tool details avoid token values, abort signals, runtime objects, and unbounded raw command/API output.
61
61
  - Automatic and explicit context include branch/upstream/ahead-behind state, working-tree counts, up to 20 unstaged/untracked entries, related open PR state, and up to 5 recent commits. Explicit `branch_status` may additionally verify one strict local or remote-tracking source/target ancestry query; automatic context never does. Metadata values default to 512 characters and rendered context to 4,000 characters.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/branchme",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "description": "Pi extension for verified Git repository initialization, branch, worktree, integration, retirement, push, and pull request workflows.",
6
6
  "license": "MIT",
package/src/constants.ts CHANGED
@@ -56,7 +56,11 @@ export const BRANCHME_TOOL_NAMES = [
56
56
  REMOVE_WORKTREE_TOOL_NAME,
57
57
  ] as const;
58
58
 
59
- export const GIT_STATUS_TIMEOUT_MS = 5_000;
59
+ // Emulated git (e.g. Rosetta in a container) under parallel agents needs far more than 5 s for a status read.
60
+ export const GIT_STATUS_TIMEOUT_MS = 30_000;
61
+ // A mutation that finds another git process's *.lock retries every interval until the wait ends.
62
+ export const GIT_LOCK_RETRY_INTERVAL_MS = 250;
63
+ export const GIT_LOCK_WAIT_MS = 10_000;
60
64
  export const GIT_MUTATION_TIMEOUT_MS = 30_000;
61
65
  export const GIT_FETCH_TIMEOUT_MS = 120_000;
62
66
  export const GIT_PULL_TIMEOUT_MS = 120_000;
package/src/git.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
3
4
  import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
5
  import {
5
6
  GIT_BRANCH_ENTRY_LIMIT,
@@ -9,6 +10,8 @@ import {
9
10
  GIT_CONTEXT_RECENT_COMMIT_LIMIT,
10
11
  GIT_CONTEXT_VALUE_LIMIT_CHARS,
11
12
  GIT_FETCH_TIMEOUT_MS,
13
+ GIT_LOCK_RETRY_INTERVAL_MS,
14
+ GIT_LOCK_WAIT_MS,
12
15
  GIT_MUTATION_TIMEOUT_MS,
13
16
  GIT_PULL_TIMEOUT_MS,
14
17
  GIT_PUSH_TIMEOUT_MS,
@@ -128,17 +131,41 @@ export function formatGitFailure(args: readonly string[], result: GitExecResult,
128
131
  return `${safeCommandLabel(args, tokens)} failed${killed}: ${reason}`;
129
132
  }
130
133
 
131
- export async function runGit(
134
+ // git's "Unable to create '<path>.lock': File exists" for index.lock, HEAD.lock
135
+ // and ref locks: another git process holds the lock.
136
+ const GIT_LOCK_HELD_PATTERN = /Unable to create '[^'\n]*\.lock': File exists/u;
137
+
138
+ async function runGitWithLockRetry(
132
139
  pi: Pick<ExtensionAPI, "exec">,
133
140
  ctx: GitCommandContext,
134
141
  args: string[],
135
- options: GitRunOptions = {},
142
+ options: GitRunOptions,
143
+ deadline: number,
136
144
  ): Promise<GitExecResult> {
137
145
  const result = await pi.exec("git", args, {
138
146
  cwd: ctx.cwd,
139
147
  signal: options.signal,
140
148
  timeout: options.timeout ?? GIT_STATUS_TIMEOUT_MS,
141
149
  });
150
+ // Retries depend on the previous failure and must never run concurrently.
151
+ // The lock belongs to another git process and is never touched here.
152
+ if (result.killed || result.code === 0 || !GIT_LOCK_HELD_PATTERN.test(result.stderr) || options.signal?.aborted) {
153
+ return result;
154
+ }
155
+ if (Date.now() >= deadline) {
156
+ return { ...result, stderr: `${result.stderr.trimEnd()}\nThe lock was still held after waiting ${GIT_LOCK_WAIT_MS / 1000} s.` };
157
+ }
158
+ await sleep(GIT_LOCK_RETRY_INTERVAL_MS, undefined, { signal: options.signal });
159
+ return runGitWithLockRetry(pi, ctx, args, options, deadline);
160
+ }
161
+
162
+ export async function runGit(
163
+ pi: Pick<ExtensionAPI, "exec">,
164
+ ctx: GitCommandContext,
165
+ args: string[],
166
+ options: GitRunOptions = {},
167
+ ): Promise<GitExecResult> {
168
+ const result = await runGitWithLockRetry(pi, ctx, args, options, Date.now() + GIT_LOCK_WAIT_MS);
142
169
 
143
170
  if (result.killed) {
144
171
  throw new Error(formatGitFailure(args, result, options.tokens));