@webpieces/repo-workflow-core 0.4.976 → 0.4.978

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.
@@ -4,34 +4,89 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.CommandTimeouts = void 0;
5
5
  const tslib_1 = require("tslib");
6
6
  const inversify_1 = require("inversify");
7
+ const tooling_common_1 = require("@webpieces/tooling-common");
7
8
  /**
8
- * The ceiling on every external command the detached main-sync refresher runs (#1342).
9
+ * The ceiling on every external command the detached main-sync refresher runs (#1342), and the ONE place
10
+ * those commands are bounded.
9
11
  *
10
12
  * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the
11
13
  * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to
12
- * be the whole of hang recovery, and it was not enough: no `spawnSync` here carried a `timeout`, so a
14
+ * be the whole of hang recovery, and it was not enough: no command here carried a deadline, so a
13
15
  * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and
14
16
  * after every reclaim the next refresher could stack another one beside it. A bounded command makes a
15
17
  * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the
16
- * backstop for a process that is stuck somewhere no timeout reaches.
18
+ * backstop for a process that is stuck somewhere no deadline reaches.
17
19
  *
18
20
  * Sized so ONE network command plus a handful of local ones stays well inside the default
19
- * 5-minute hang window. Node sends SIGTERM on expiry (the spawnSync default), which lets git clean up
20
- * its own `.lock` files rather than leaving them behind as SIGKILL would.
21
+ * 5-minute hang window. The kill is SIGTERM (node's default), which lets git clean up its own `.lock`
22
+ * files rather than leaving them behind as SIGKILL would.
23
+ *
24
+ * HOW (#1381): the command goes through the process edges, and its deadline is a task scheduled on the
25
+ * clock edge (`TimerApi`) that aborts the run. A feature test therefore SEES each requested deadline
26
+ * (`MockTimerApi.getScheduledTasks()`), and fires it itself to prove the timeout path — no real time
27
+ * passes. The one synchronous form ({@link localGitNow}) exists for the PreToolUse guard's blocking path,
28
+ * whose `check()` contract is synchronous; its kill-after is passed straight to the edge.
21
29
  */
22
30
  let CommandTimeouts = class CommandTimeouts {
23
31
  static { CommandTimeouts_1 = this; }
32
+ git;
33
+ processes;
34
+ timer;
24
35
  /** `git fetch` and `gh` — the two calls that cross the network. */
25
36
  static NETWORK_MS = 90 * 1000;
26
37
  /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */
27
38
  static LOCAL_GIT_MS = 30 * 1000;
28
- /** The ceiling for one captured command: `gh` talks to GitHub, everything else here is local git. */
29
- forCommand(cmd) {
30
- return cmd === 'gh' ? CommandTimeouts_1.NETWORK_MS : CommandTimeouts_1.LOCAL_GIT_MS;
39
+ // Defaulted so the non-DI call sites (the guard, the detached refresher) can `new` this, while
40
+ // inversify injects the container's edges — which is what a feature test rebinds.
41
+ constructor(git = new tooling_common_1.GitProcessClient(), processes = new tooling_common_1.NativeProcessClient(), timer = new tooling_common_1.TimerClient()) {
42
+ this.git = git;
43
+ this.processes = processes;
44
+ this.timer = timer;
45
+ }
46
+ /** Local git, killed after {@link LOCAL_GIT_MS}. */
47
+ localGit(args, cwd) {
48
+ return this.boundedGit(args, cwd, CommandTimeouts_1.LOCAL_GIT_MS);
49
+ }
50
+ /** Network git (`fetch`), killed after {@link NETWORK_MS}. */
51
+ networkGit(args, cwd) {
52
+ return this.boundedGit(args, cwd, CommandTimeouts_1.NETWORK_MS);
53
+ }
54
+ /** `gh`, killed after {@link NETWORK_MS}. */
55
+ async gh(args, cwd) {
56
+ const abort = new AbortController();
57
+ const deadline = this.deadline(CommandTimeouts_1.NETWORK_MS, `gh ${args.join(' ')}`, abort);
58
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
59
+ try {
60
+ return await this.processes.run('gh', args, cwd, abort.signal);
61
+ }
62
+ finally {
63
+ deadline.cancel();
64
+ }
65
+ }
66
+ /** Local git, SYNCHRONOUSLY — the guard's blocking path only. Killed after {@link LOCAL_GIT_MS}. */
67
+ localGitNow(args, cwd) {
68
+ return this.git.runSync(args, cwd, CommandTimeouts_1.LOCAL_GIT_MS);
69
+ }
70
+ async boundedGit(args, cwd, timeoutMs) {
71
+ const abort = new AbortController();
72
+ const deadline = this.deadline(timeoutMs, `git ${args.join(' ')}`, abort);
73
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
74
+ try {
75
+ return await this.git.run(args, cwd, abort.signal);
76
+ }
77
+ finally {
78
+ deadline.cancel();
79
+ }
80
+ }
81
+ deadline(timeoutMs, command, abort) {
82
+ return this.timer.schedule(timeoutMs, new tooling_common_1.TaskDto(`deadline for \`${command}\``, () => abort.abort()));
31
83
  }
32
84
  };
33
85
  exports.CommandTimeouts = CommandTimeouts;
34
86
  exports.CommandTimeouts = CommandTimeouts = CommandTimeouts_1 = tslib_1.__decorate([
35
- (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
87
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
88
+ tslib_1.__metadata("design:paramtypes", [tooling_common_1.GitProcessApi,
89
+ tooling_common_1.NativeProcessApi,
90
+ tooling_common_1.TimerApi])
36
91
  ], CommandTimeouts);
37
92
  //# sourceMappingURL=command-timeouts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"command-timeouts.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/command-timeouts.ts"],"names":[],"mappings":";;;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;GAcG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAe;;IACxB,mEAAmE;IACnE,MAAM,CAAU,UAAU,GAAG,EAAE,GAAG,IAAI,CAAC;IACvC,oHAAoH;IACpH,MAAM,CAAU,YAAY,GAAG,EAAE,GAAG,IAAI,CAAC;IAEzC,qGAAqG;IACrG,UAAU,CAAC,GAAW;QAClB,OAAO,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,iBAAe,CAAC,UAAU,CAAC,CAAC,CAAC,iBAAe,CAAC,YAAY,CAAC;IACpF,CAAC;;AATQ,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,eAAe,CAU3B","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * The ceiling on every external command the detached main-sync refresher runs (#1342).\n *\n * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the\n * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to\n * be the whole of hang recovery, and it was not enough: no `spawnSync` here carried a `timeout`, so a\n * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and\n * after every reclaim the next refresher could stack another one beside it. A bounded command makes a\n * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the\n * backstop for a process that is stuck somewhere no timeout reaches.\n *\n * Sized so ONE network command plus a handful of local ones stays well inside the default\n * 5-minute hang window. Node sends SIGTERM on expiry (the spawnSync default), which lets git clean up\n * its own `.lock` files rather than leaving them behind as SIGKILL would.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CommandTimeouts {\n /** `git fetch` and `gh` — the two calls that cross the network. */\n static readonly NETWORK_MS = 90 * 1000;\n /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */\n static readonly LOCAL_GIT_MS = 30 * 1000;\n\n /** The ceiling for one captured command: `gh` talks to GitHub, everything else here is local git. */\n forCommand(cmd: string): number {\n return cmd === 'gh' ? CommandTimeouts.NETWORK_MS : CommandTimeouts.LOCAL_GIT_MS;\n }\n}\n"]}
1
+ {"version":3,"file":"command-timeouts.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/command-timeouts.ts"],"names":[],"mappings":";;;;;AAAA,yCAA2D;AAC3D,8DAWmC;AAEnC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAe;;IASH;IACA;IACA;IAVrB,mEAAmE;IACnE,MAAM,CAAU,UAAU,GAAG,EAAE,GAAG,IAAI,CAAC;IACvC,oHAAoH;IACpH,MAAM,CAAU,YAAY,GAAG,EAAE,GAAG,IAAI,CAAC;IAEzC,+FAA+F;IAC/F,kFAAkF;IAClF,YACqB,MAAqB,IAAI,iCAAgB,EAAE,EAC3C,YAA8B,IAAI,oCAAmB,EAAE,EACvD,QAAkB,IAAI,4BAAW,EAAE;QAFnC,QAAG,GAAH,GAAG,CAAwC;QAC3C,cAAS,GAAT,SAAS,CAA8C;QACvD,UAAK,GAAL,KAAK,CAA8B;IACrD,CAAC;IAEJ,oDAAoD;IACpD,QAAQ,CAAC,IAAc,EAAE,GAAW;QAChC,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,iBAAe,CAAC,YAAY,CAAC,CAAC;IACpE,CAAC;IAED,8DAA8D;IAC9D,UAAU,CAAC,IAAc,EAAE,GAAW;QAClC,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,iBAAe,CAAC,UAAU,CAAC,CAAC;IAClE,CAAC;IAED,6CAA6C;IAC7C,KAAK,CAAC,EAAE,CAAC,IAAc,EAAE,GAAW;QAChC,MAAM,KAAK,GAAG,IAAI,eAAe,EAAE,CAAC;QACpC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,iBAAe,CAAC,UAAU,EAAE,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QAC1F,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,MAAM,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;QACnE,CAAC;gBAAS,CAAC;YACP,QAAQ,CAAC,MAAM,EAAE,CAAC;QACtB,CAAC;IACL,CAAC;IAED,oGAAoG;IACpG,WAAW,CAAC,IAAc,EAAE,GAAW;QACnC,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,EAAE,iBAAe,CAAC,YAAY,CAAC,CAAC;IACrE,CAAC;IAEO,KAAK,CAAC,UAAU,CAAC,IAAc,EAAE,GAAW,EAAE,SAAiB;QACnE,MAAM,KAAK,GAAG,IAAI,eAAe,EAAE,CAAC;QACpC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QAC1E,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,MAAM,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;QACvD,CAAC;gBAAS,CAAC;YACP,QAAQ,CAAC,MAAM,EAAE,CAAC;QACtB,CAAC;IACL,CAAC;IAEO,QAAQ,CAAC,SAAiB,EAAE,OAAe,EAAE,KAAsB;QACvE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,EAAE,IAAI,wBAAO,CAAC,kBAAkB,OAAO,IAAI,EAAE,GAAS,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACjH,CAAC;;AAtDQ,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAUX,8BAAa;QACP,iCAAgB;QACpB,yBAAQ;GAX3B,eAAe,CAuD3B","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport {\n GitProcessApi,\n GitProcessClient,\n GitResultDto,\n NativeProcessApi,\n NativeProcessClient,\n ProcessResultDto,\n ScheduledTaskDto,\n TaskDto,\n TimerApi,\n TimerClient,\n} from '@webpieces/tooling-common';\n\n/**\n * The ceiling on every external command the detached main-sync refresher runs (#1342), and the ONE place\n * those commands are bounded.\n *\n * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the\n * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to\n * be the whole of hang recovery, and it was not enough: no command here carried a deadline, so a\n * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and\n * after every reclaim the next refresher could stack another one beside it. A bounded command makes a\n * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the\n * backstop for a process that is stuck somewhere no deadline reaches.\n *\n * Sized so ONE network command plus a handful of local ones stays well inside the default\n * 5-minute hang window. The kill is SIGTERM (node's default), which lets git clean up its own `.lock`\n * files rather than leaving them behind as SIGKILL would.\n *\n * HOW (#1381): the command goes through the process edges, and its deadline is a task scheduled on the\n * clock edge (`TimerApi`) that aborts the run. A feature test therefore SEES each requested deadline\n * (`MockTimerApi.getScheduledTasks()`), and fires it itself to prove the timeout path — no real time\n * passes. The one synchronous form ({@link localGitNow}) exists for the PreToolUse guard's blocking path,\n * whose `check()` contract is synchronous; its kill-after is passed straight to the edge.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CommandTimeouts {\n /** `git fetch` and `gh` — the two calls that cross the network. */\n static readonly NETWORK_MS = 90 * 1000;\n /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */\n static readonly LOCAL_GIT_MS = 30 * 1000;\n\n // Defaulted so the non-DI call sites (the guard, the detached refresher) can `new` this, while\n // inversify injects the container's edges — which is what a feature test rebinds.\n constructor(\n private readonly git: GitProcessApi = new GitProcessClient(),\n private readonly processes: NativeProcessApi = new NativeProcessClient(),\n private readonly timer: TimerApi = new TimerClient(),\n ) {}\n\n /** Local git, killed after {@link LOCAL_GIT_MS}. */\n localGit(args: string[], cwd: string): Promise<GitResultDto> {\n return this.boundedGit(args, cwd, CommandTimeouts.LOCAL_GIT_MS);\n }\n\n /** Network git (`fetch`), killed after {@link NETWORK_MS}. */\n networkGit(args: string[], cwd: string): Promise<GitResultDto> {\n return this.boundedGit(args, cwd, CommandTimeouts.NETWORK_MS);\n }\n\n /** `gh`, killed after {@link NETWORK_MS}. */\n async gh(args: string[], cwd: string): Promise<ProcessResultDto> {\n const abort = new AbortController();\n const deadline = this.deadline(CommandTimeouts.NETWORK_MS, `gh ${args.join(' ')}`, abort);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.processes.run('gh', args, cwd, abort.signal);\n } finally {\n deadline.cancel();\n }\n }\n\n /** Local git, SYNCHRONOUSLY — the guard's blocking path only. Killed after {@link LOCAL_GIT_MS}. */\n localGitNow(args: string[], cwd: string): GitResultDto {\n return this.git.runSync(args, cwd, CommandTimeouts.LOCAL_GIT_MS);\n }\n\n private async boundedGit(args: string[], cwd: string, timeoutMs: number): Promise<GitResultDto> {\n const abort = new AbortController();\n const deadline = this.deadline(timeoutMs, `git ${args.join(' ')}`, abort);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.git.run(args, cwd, abort.signal);\n } finally {\n deadline.cancel();\n }\n }\n\n private deadline(timeoutMs: number, command: string, abort: AbortController): ScheduledTaskDto {\n return this.timer.schedule(timeoutMs, new TaskDto(`deadline for \\`${command}\\``, (): void => abort.abort()));\n }\n}\n"]}
@@ -14,8 +14,8 @@ export declare class MainSyncStatusService {
14
14
  private readonly atomicFile;
15
15
  private readonly store;
16
16
  private readonly worktrees;
17
- private readonly timeouts;
18
- constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile, store?: MainSyncFileStore, worktrees?: WorktreeService, timeouts?: CommandTimeouts);
17
+ private readonly commands;
18
+ constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile, store?: MainSyncFileStore, worktrees?: WorktreeService, commands?: CommandTimeouts);
19
19
  /**
20
20
  * SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
21
21
  *
@@ -71,7 +71,7 @@ export declare class MainSyncStatusService {
71
71
  * own `gh` query, so it is the right entry point for a single-worktree caller and for tests.
72
72
  * The refresher uses computeAllMainSyncStatuses instead — see there for why.
73
73
  */
74
- computeMainSyncStatus(repoRoot: string): MainSyncStatus;
74
+ computeMainSyncStatus(repoRoot: string): Promise<MainSyncStatus>;
75
75
  /**
76
76
  * The SLOW path for EVERY branch this repo has checked out — what the detached refresher runs.
77
77
  *
@@ -87,7 +87,7 @@ export declare class MainSyncStatusService {
87
87
  * The map is rebuilt from the LIVE worktree set every refresh, so a deleted branch or removed
88
88
  * worktree simply stops appearing. No pruning pass, no unbounded growth.
89
89
  */
90
- computeAllMainSyncStatuses(repoRoot: string): MainSyncStatusFile;
90
+ computeAllMainSyncStatuses(repoRoot: string): Promise<MainSyncStatusFile>;
91
91
  private hasBranch;
92
92
  /**
93
93
  * One branch's status, computed in `worktreePath` (the tree that has it checked out — the
@@ -105,7 +105,7 @@ export declare class MainSyncStatusService {
105
105
  * goes through writeMainSyncStatus, which MERGES its entry into the existing map; writing a whole
106
106
  * document from here would wipe every other worktree's entry.
107
107
  */
108
- stampCleanMainSyncStatus(repoRoot: string): void;
108
+ stampCleanMainSyncStatus(repoRoot: string): Promise<void>;
109
109
  /**
110
110
  * Refresh `origin/main` WITHOUT writing `.git/FETCH_HEAD`.
111
111
  *
@@ -123,7 +123,7 @@ export declare class MainSyncStatusService {
123
123
  */
124
124
  private fetchOriginMain;
125
125
  private isUnknownGitOption;
126
- private capture;
126
+ private git;
127
127
  private gitBranch;
128
128
  private localMainHash;
129
129
  private commitDistance;
@@ -149,7 +149,4 @@ export declare function readMainSyncStatus(repoRoot: string, branch: string): Ma
149
149
  export declare function readMainSyncStatusFile(repoRoot: string): MainSyncStatusFile | null;
150
150
  export declare function writeMainSyncStatus(repoRoot: string, status: MainSyncStatus): void;
151
151
  export declare function writeMainSyncStatusFile(repoRoot: string, file: MainSyncStatusFile): void;
152
- export declare function computeMainSyncStatus(repoRoot: string): MainSyncStatus;
153
- export declare function computeAllMainSyncStatuses(repoRoot: string): MainSyncStatusFile;
154
152
  export declare function squashRecoverySteps(currentBranch: string): string[];
155
- export declare function stampCleanMainSyncStatus(repoRoot: string): void;
@@ -6,12 +6,8 @@ exports.readMainSyncStatus = readMainSyncStatus;
6
6
  exports.readMainSyncStatusFile = readMainSyncStatusFile;
7
7
  exports.writeMainSyncStatus = writeMainSyncStatus;
8
8
  exports.writeMainSyncStatusFile = writeMainSyncStatusFile;
9
- exports.computeMainSyncStatus = computeMainSyncStatus;
10
- exports.computeAllMainSyncStatuses = computeAllMainSyncStatuses;
11
9
  exports.squashRecoverySteps = squashRecoverySteps;
12
- exports.stampCleanMainSyncStatus = stampCleanMainSyncStatus;
13
10
  const tslib_1 = require("tslib");
14
- const child_process_1 = require("child_process");
15
11
  const inversify_1 = require("inversify");
16
12
  const tooling_common_1 = require("@webpieces/tooling-common");
17
13
  const main_sync_file_1 = require("./main-sync-file");
@@ -32,6 +28,15 @@ const command_timeouts_1 = require("./command-timeouts");
32
28
  // and reclaims the lock (see MainSyncLockService).
33
29
  exports.DEFAULT_HANG_TIMEOUT_MINUTES = 5;
34
30
  const MAIN_SYNC_STATUS_FILE = 'main-sync-status.json';
31
+ // Internal: one bounded command's verdict and trimmed stdout. Data only.
32
+ class CapturedOutput {
33
+ ok;
34
+ out;
35
+ constructor(ok, out) {
36
+ this.ok = ok;
37
+ this.out = out;
38
+ }
39
+ }
35
40
  /**
36
41
  * Reads/writes the main-sync cache and computes the slow "is my branch healthy vs origin/main?"
37
42
  * status. `@injectable(bindingScopeValues.Singleton)` so it's injectable and drawn in consumers’ DI designs.
@@ -41,13 +46,13 @@ let MainSyncStatusService = class MainSyncStatusService {
41
46
  atomicFile;
42
47
  store;
43
48
  worktrees;
44
- timeouts;
45
- constructor(dotDir = tooling_common_2.dotWebpieces, atomicFile = new tooling_common_1.AtomicFile(), store = new main_sync_file_1.MainSyncFileStore(atomicFile), worktrees = new worktrees_1.WorktreeService(), timeouts = new command_timeouts_1.CommandTimeouts()) {
49
+ commands;
50
+ constructor(dotDir = tooling_common_2.dotWebpieces, atomicFile = new tooling_common_1.AtomicFile(), store = new main_sync_file_1.MainSyncFileStore(atomicFile), worktrees = new worktrees_1.WorktreeService(), commands = new command_timeouts_1.CommandTimeouts()) {
46
51
  this.dotDir = dotDir;
47
52
  this.atomicFile = atomicFile;
48
53
  this.store = store;
49
54
  this.worktrees = worktrees;
50
- this.timeouts = timeouts;
55
+ this.commands = commands;
51
56
  }
52
57
  /**
53
58
  * SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
@@ -117,10 +122,10 @@ let MainSyncStatusService = class MainSyncStatusService {
117
122
  * own `gh` query, so it is the right entry point for a single-worktree caller and for tests.
118
123
  * The refresher uses computeAllMainSyncStatuses instead — see there for why.
119
124
  */
120
- computeMainSyncStatus(repoRoot) {
125
+ async computeMainSyncStatus(repoRoot) {
121
126
  // Best-effort network refresh; offline just means we evaluate against the last-fetched ref.
122
- this.fetchOriginMain(repoRoot);
123
- return this.computeBranchStatus(repoRoot, this.gitBranch(repoRoot), this.loadPullRequestIndex(repoRoot));
127
+ await this.fetchOriginMain(repoRoot);
128
+ return this.computeBranchStatus(repoRoot, this.gitBranch(repoRoot), await this.loadPullRequestIndex(repoRoot));
124
129
  }
125
130
  /**
126
131
  * The SLOW path for EVERY branch this repo has checked out — what the detached refresher runs.
@@ -137,21 +142,21 @@ let MainSyncStatusService = class MainSyncStatusService {
137
142
  * The map is rebuilt from the LIVE worktree set every refresh, so a deleted branch or removed
138
143
  * worktree simply stops appearing. No pruning pass, no unbounded growth.
139
144
  */
140
- computeAllMainSyncStatuses(repoRoot) {
141
- this.fetchOriginMain(repoRoot);
142
- const index = this.loadPullRequestIndex(repoRoot);
145
+ async computeAllMainSyncStatuses(repoRoot) {
146
+ await this.fetchOriginMain(repoRoot);
147
+ const index = await this.loadPullRequestIndex(repoRoot);
143
148
  const branches = {};
144
149
  for (const tree of this.worktrees.listWorktrees(repoRoot)) {
145
150
  // Detached HEAD (and a bare worktree) has no branch to key an entry on. Skipping it must
146
151
  // not stop the others being recorded, which is why this is a `continue`, not a bail-out.
147
152
  if (tree.branch === '' || this.hasBranch(branches, tree.branch))
148
153
  continue;
149
- branches[tree.branch] = this.computeBranchStatus(tree.path, tree.branch, index);
154
+ branches[tree.branch] = await this.computeBranchStatus(tree.path, tree.branch, index);
150
155
  }
151
156
  // `main` always present, even with no worktree on it: read-stale-guard's state A looks itself
152
157
  // up under 'main', and an absent entry there would silently disarm the stale-main block.
153
158
  if (!this.hasBranch(branches, 'main'))
154
- branches['main'] = this.mainOnlyStatus(repoRoot);
159
+ branches['main'] = await this.mainOnlyStatus(repoRoot);
155
160
  return new main_sync_file_1.MainSyncStatusFile(main_sync_file_1.MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);
156
161
  }
157
162
  hasBranch(branches, branch) {
@@ -163,8 +168,8 @@ let MainSyncStatusService = class MainSyncStatusService {
163
168
  * `gh`: both are done once per refresh by the caller.
164
169
  */
165
170
  // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
166
- computeBranchStatus(repoRoot, branch, index) {
167
- return this.stampForge(this.computeBranchStatusInner(repoRoot, branch, index), index);
171
+ async computeBranchStatus(repoRoot, branch, index) {
172
+ return this.stampForge(await this.computeBranchStatusInner(repoRoot, branch, index), index);
168
173
  }
169
174
  // Every return path of the computation below has to carry the forge flag, and threading it through
170
175
  // four constructions is how one of them would end up missing it. One wrapper, one assignment.
@@ -173,12 +178,12 @@ let MainSyncStatusService = class MainSyncStatusService {
173
178
  return status;
174
179
  }
175
180
  // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
176
- computeBranchStatusInner(repoRoot, branch, index) {
181
+ async computeBranchStatusInner(repoRoot, branch, index) {
177
182
  const mergedPr = index.mergedFor(branch);
178
183
  const openPr = index.openFor(branch);
179
- const head = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
180
- const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
181
- const localMain = this.localMainHash(repoRoot);
184
+ const head = await this.git(repoRoot, ['rev-parse', 'HEAD']);
185
+ const originMain = await this.git(repoRoot, ['rev-parse', 'origin/main']);
186
+ const localMain = await this.localMainHash(repoRoot);
182
187
  const featureHead = head.ok ? head.out : '';
183
188
  if (!head.ok || !originMain.ok) {
184
189
  const status = this.benignStatus(branch, featureHead);
@@ -186,24 +191,24 @@ let MainSyncStatusService = class MainSyncStatusService {
186
191
  status.mergedPr = mergedPr;
187
192
  status.openPr = openPr;
188
193
  status.localMain = localMain;
189
- status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
194
+ status.commitsBehind = await this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
190
195
  return status;
191
196
  }
192
- const forkPoint = this.capture(repoRoot, 'git', ['merge-base', 'origin/main', 'HEAD']);
197
+ const forkPoint = await this.git(repoRoot, ['merge-base', 'origin/main', 'HEAD']);
193
198
  if (!forkPoint.ok || forkPoint.out === '') {
194
199
  const noFork = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
195
200
  noFork.openPr = openPr;
196
201
  noFork.localMain = localMain;
197
- noFork.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.out);
202
+ noFork.commitsBehind = await this.commitDistance(repoRoot, localMain, originMain.out);
198
203
  return noFork;
199
204
  }
200
- const featureFiles = new Set(this.featureChangedFiles(repoRoot, forkPoint.out));
201
- const mainFiles = this.changedFiles(repoRoot, forkPoint.out, 'origin/main');
205
+ const featureFiles = new Set(await this.featureChangedFiles(repoRoot, forkPoint.out));
206
+ const mainFiles = await this.changedFiles(repoRoot, forkPoint.out, 'origin/main');
202
207
  const conflictFiles = mainFiles.filter((file) => featureFiles.has(file));
203
208
  const status = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
204
209
  status.openPr = openPr;
205
210
  status.localMain = localMain;
206
- status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.out);
211
+ status.commitsBehind = await this.commitDistance(repoRoot, localMain, originMain.out);
207
212
  return status;
208
213
  }
209
214
  // The recovery steps when there is no fork point with origin/main (a bad merge of main into branch).
@@ -229,16 +234,16 @@ let MainSyncStatusService = class MainSyncStatusService {
229
234
  * goes through writeMainSyncStatus, which MERGES its entry into the existing map; writing a whole
230
235
  * document from here would wipe every other worktree's entry.
231
236
  */
232
- stampCleanMainSyncStatus(repoRoot) {
237
+ async stampCleanMainSyncStatus(repoRoot) {
233
238
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
234
239
  try {
235
240
  const branch = this.gitBranch(repoRoot);
236
- const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
237
- const featureHead = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
241
+ const originMain = await this.git(repoRoot, ['rev-parse', 'origin/main']);
242
+ const featureHead = await this.git(repoRoot, ['rev-parse', 'HEAD']);
238
243
  if (!originMain.ok || !featureHead.ok)
239
244
  return;
240
245
  const status = new main_sync_file_1.MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
241
- status.localMain = this.localMainHash(repoRoot);
246
+ status.localMain = await this.localMainHash(repoRoot);
242
247
  status.commitsBehind = 0;
243
248
  this.writeMainSyncStatus(repoRoot, status);
244
249
  }
@@ -262,27 +267,23 @@ let MainSyncStatusService = class MainSyncStatusService {
262
267
  * Older git rejects the flag; only then do we retry the plain form, accepting the old behaviour
263
268
  * rather than losing the refresh entirely on a pre-2020 git.
264
269
  */
265
- fetchOriginMain(repoRoot) {
266
- const safe = (0, child_process_1.spawnSync)('git', ['fetch', '--no-write-fetch-head', 'origin', 'main'], {
267
- cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: command_timeouts_1.CommandTimeouts.NETWORK_MS,
268
- });
269
- if (safe.status === 0)
270
+ async fetchOriginMain(repoRoot) {
271
+ const safe = await this.commands.networkGit(['fetch', '--no-write-fetch-head', 'origin', 'main'], repoRoot);
272
+ if (safe.ok)
270
273
  return;
271
274
  if (!this.isUnknownGitOption(safe.stderr))
272
275
  return; // a real failure (offline, auth) — not our business
273
- (0, child_process_1.spawnSync)('git', ['fetch', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore', timeout: command_timeouts_1.CommandTimeouts.NETWORK_MS });
276
+ await this.commands.networkGit(['fetch', 'origin', 'main'], repoRoot);
274
277
  }
275
278
  // Did git reject the flag itself (too old), as opposed to failing the network refresh?
276
279
  isUnknownGitOption(stderr) {
277
- const text = (stderr ?? '').toLowerCase();
280
+ const text = stderr.toLowerCase();
278
281
  return text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');
279
282
  }
280
- // Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
281
- capture(repoRoot, cmd, args) {
282
- const result = (0, child_process_1.spawnSync)(cmd, args, { cwd: repoRoot, encoding: 'utf8', timeout: this.timeouts.forCommand(cmd) });
283
- if (result.status !== 0 || typeof result.stdout !== 'string')
284
- return { ok: false, out: '' };
285
- return { ok: true, out: result.stdout.trim() };
283
+ // Local git, bounded (CommandTimeouts), stdout trimmed; ok=false on a non-zero exit, a kill or a failed start.
284
+ async git(repoRoot, args) {
285
+ const result = await this.commands.localGit(args, repoRoot);
286
+ return new CapturedOutput(result.ok, result.ok ? result.stdout.trim() : '');
286
287
  }
287
288
  // The actual checked-out branch in repoRoot — cwd-correct so the cache's `branch` label matches.
288
289
  gitBranch(repoRoot) {
@@ -290,31 +291,31 @@ let MainSyncStatusService = class MainSyncStatusService {
290
291
  }
291
292
  // The LOCAL main hash. `refs/heads/main` (not the bare name) so it can never resolve to a remote
292
293
  // ref or a tag. '' when main does not exist locally — which the guard treats as fail-open.
293
- localMainHash(repoRoot) {
294
- const result = this.capture(repoRoot, 'git', ['rev-parse', 'refs/heads/main']);
294
+ async localMainHash(repoRoot) {
295
+ const result = await this.git(repoRoot, ['rev-parse', 'refs/heads/main']);
295
296
  return result.ok ? result.out : '';
296
297
  }
297
298
  // Slow-path only: the detached refresher calls this after fetching. Guards never count commits.
298
- commitDistance(repoRoot, localMain, originMain) {
299
+ async commitDistance(repoRoot, localMain, originMain) {
299
300
  if (localMain === '' || originMain === '')
300
301
  return null;
301
- const result = this.capture(repoRoot, 'git', ['rev-list', '--count', `${localMain}..${originMain}`]);
302
+ const result = await this.git(repoRoot, ['rev-list', '--count', `${localMain}..${originMain}`]);
302
303
  if (!result.ok || !/^\d+$/.test(result.out))
303
304
  return null;
304
305
  return Number(result.out);
305
306
  }
306
- changedFiles(repoRoot, base, head) {
307
- const result = this.capture(repoRoot, 'git', ['diff', '--name-only', base, head]);
307
+ async changedFiles(repoRoot, base, head) {
308
+ const result = await this.git(repoRoot, ['diff', '--name-only', base, head]);
308
309
  if (!result.ok || result.out === '')
309
310
  return [];
310
311
  return result.out.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
311
312
  }
312
313
  // Every file this feature branch has touched since the fork point — committed AND still in the
313
314
  // working tree (staged / unstaged / untracked), so a conflict is visible WHILE editing.
314
- featureChangedFiles(repoRoot, forkPoint) {
315
+ async featureChangedFiles(repoRoot, forkPoint) {
315
316
  const out = new Set();
316
- const add = (args) => {
317
- const r = this.capture(repoRoot, 'git', args);
317
+ const add = async (args) => {
318
+ const r = await this.git(repoRoot, args);
318
319
  if (!r.ok || r.out === '')
319
320
  return;
320
321
  for (const line of r.out.split('\n')) {
@@ -323,10 +324,10 @@ let MainSyncStatusService = class MainSyncStatusService {
323
324
  out.add(f);
324
325
  }
325
326
  };
326
- add(['diff', '--name-only', forkPoint, 'HEAD']); // committed since the fork point
327
- add(['diff', '--name-only', 'HEAD']); // unstaged working-tree edits
328
- add(['diff', '--name-only', '--cached', 'HEAD']); // staged edits
329
- add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
327
+ await add(['diff', '--name-only', forkPoint, 'HEAD']); // committed since the fork point
328
+ await add(['diff', '--name-only', 'HEAD']); // unstaged working-tree edits
329
+ await add(['diff', '--name-only', '--cached', 'HEAD']); // staged edits
330
+ await add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
330
331
  return [...out];
331
332
  }
332
333
  /**
@@ -335,10 +336,11 @@ let MainSyncStatusService = class MainSyncStatusService {
335
336
  * falls out of the index and reads as "no PR", which is the fail-OPEN direction (no merged PR =
336
337
  * no block), the same way a missing `gh` or an offline machine already degraded.
337
338
  */
338
- loadPullRequestIndex(repoRoot) {
339
- const result = this.capture(repoRoot, 'gh', [
339
+ async loadPullRequestIndex(repoRoot) {
340
+ const gh = await this.commands.gh([
340
341
  'pr', 'list', '--state', 'all', '--json', 'number,headRefName,state', '--limit', '200',
341
- ]);
342
+ ], repoRoot);
343
+ const result = new CapturedOutput(gh.ok, gh.ok ? gh.stdout.trim() : '');
342
344
  // `gh` could not be run, or exited non-zero (missing, unauthenticated, rate-limited, offline).
343
345
  // An EMPTY index is the same fail-open answer as before — but it is now labelled UNREACHABLE, so
344
346
  // the guards can report an abstention rather than an approval. Empty STDOUT on a zero exit is a
@@ -354,12 +356,12 @@ let MainSyncStatusService = class MainSyncStatusService {
354
356
  * read-stale-guard / stale-main-bash-guard actually compare (originMain vs the live tree's
355
357
  * ancestry) — never merged, never conflicting, so it can only ever produce the stale-main verdict.
356
358
  */
357
- mainOnlyStatus(repoRoot) {
358
- const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
359
- const localMain = this.localMainHash(repoRoot);
359
+ async mainOnlyStatus(repoRoot) {
360
+ const originMain = await this.git(repoRoot, ['rev-parse', 'origin/main']);
361
+ const localMain = await this.localMainHash(repoRoot);
360
362
  const status = new main_sync_file_1.MainSyncStatus('main', false, '', true, localMain === '' ? null : localMain, originMain.ok ? originMain.out : '', localMain, false, [], new Date().toISOString());
361
363
  status.localMain = localMain;
362
- status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
364
+ status.commitsBehind = await this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
363
365
  return status;
364
366
  }
365
367
  // A benign status that never blocks — used when origin/main can't be resolved.
@@ -389,11 +391,5 @@ function writeMainSyncStatus(repoRoot, status) { mainSyncSvc.writeMainSyncStatus
389
391
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
390
392
  function writeMainSyncStatusFile(repoRoot, file) { mainSyncSvc.writeMainSyncStatusFile(repoRoot, file); }
391
393
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
392
- function computeMainSyncStatus(repoRoot) { return mainSyncSvc.computeMainSyncStatus(repoRoot); }
393
- // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
394
- function computeAllMainSyncStatuses(repoRoot) { return mainSyncSvc.computeAllMainSyncStatuses(repoRoot); }
395
- // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
396
394
  function squashRecoverySteps(currentBranch) { return mainSyncSvc.squashRecoverySteps(currentBranch); }
397
- // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
398
- function stampCleanMainSyncStatus(repoRoot) { mainSyncSvc.stampCleanMainSyncStatus(repoRoot); }
399
395
  //# sourceMappingURL=main-sync-status.js.map