collabmd 0.1.19 → 0.1.20

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.
@@ -87,6 +87,7 @@ export class GitPanelController {
87
87
  constructor({
88
88
  enabled = true,
89
89
  onCommitStaged = () => {},
90
+ onOpenPullBackup = () => {},
90
91
  onPullBranch = () => {},
91
92
  onPushBranch = () => {},
92
93
  onRepoChange = () => {},
@@ -100,6 +101,7 @@ export class GitPanelController {
100
101
  } = {}) {
101
102
  this.enabled = enabled;
102
103
  this.onCommitStaged = onCommitStaged;
104
+ this.onOpenPullBackup = onOpenPullBackup;
103
105
  this.onPullBranch = onPullBranch;
104
106
  this.onPushBranch = onPushBranch;
105
107
  this.onRepoChange = onRepoChange;
@@ -111,6 +113,7 @@ export class GitPanelController {
111
113
  this.searchInput = searchInput;
112
114
  this.toastController = toastController;
113
115
  this.panel = document.getElementById('gitPanel');
116
+ this.pullBackups = [];
114
117
  this.status = null;
115
118
  this.active = false;
116
119
  this.refreshTimer = null;
@@ -147,6 +150,18 @@ export class GitPanelController {
147
150
  return;
148
151
  }
149
152
 
153
+ const pullBackupButton = event.target instanceof Element
154
+ ? event.target.closest('[data-git-pull-backup-path]')
155
+ : null;
156
+ if (pullBackupButton) {
157
+ const summaryPath = pullBackupButton.getAttribute('data-git-pull-backup-path');
158
+ if (!summaryPath) {
159
+ return;
160
+ }
161
+ this.onOpenPullBackup(summaryPath);
162
+ return;
163
+ }
164
+
150
165
  const actionButton = event.target instanceof Element
151
166
  ? event.target.closest('[data-git-file-action]')
152
167
  : null;
@@ -243,6 +258,7 @@ export class GitPanelController {
243
258
  sections: [],
244
259
  summary: { changedFiles: 0 },
245
260
  };
261
+ this.pullBackups = [];
246
262
  this.onRepoChange(false, this.status);
247
263
  this.render();
248
264
  return this.status;
@@ -256,6 +272,19 @@ export class GitPanelController {
256
272
  }
257
273
 
258
274
  this.status = data;
275
+ this.pullBackups = [];
276
+ if (data.isGitRepo) {
277
+ try {
278
+ const backupResponse = await fetch(resolveApiUrl('/git/pull-backups'));
279
+ const backupData = await backupResponse.json();
280
+ if (!backupResponse.ok) {
281
+ throw new Error(backupData.error || 'Failed to load pull backups');
282
+ }
283
+ this.pullBackups = Array.isArray(backupData.backups) ? backupData.backups : [];
284
+ } catch (backupError) {
285
+ console.error('[git-panel] Failed to load pull backups:', backupError);
286
+ }
287
+ }
259
288
  this.onRepoChange(Boolean(data.isGitRepo), data);
260
289
  this.render();
261
290
  return data;
@@ -267,6 +296,7 @@ export class GitPanelController {
267
296
  sections: [],
268
297
  summary: { changedFiles: 0 },
269
298
  };
299
+ this.pullBackups = [];
270
300
  this.onRepoChange(false, this.status);
271
301
  this.render();
272
302
  return this.status;
@@ -367,6 +397,47 @@ export class GitPanelController {
367
397
  `;
368
398
  }
369
399
 
400
+ renderPullBackupsSection() {
401
+ if (!Array.isArray(this.pullBackups) || this.pullBackups.length === 0) {
402
+ return '';
403
+ }
404
+
405
+ return `
406
+ <section class="git-section">
407
+ <button class="git-section-header" type="button" data-git-section-toggle="pull-backups">
408
+ ${chevronSvg(this.collapsedSections.has('pull-backups'))}
409
+ Pull Backups
410
+ <span class="git-section-count">${this.pullBackups.length}</span>
411
+ </button>
412
+ <div class="git-file-list${this.collapsedSections.has('pull-backups') ? ' hidden' : ''}">
413
+ ${this.pullBackups.map((backup) => this.renderPullBackup(backup)).join('')}
414
+ </div>
415
+ </section>
416
+ `;
417
+ }
418
+
419
+ renderPullBackup(backup) {
420
+ const createdAt = String(backup?.createdAt || '').replace('T', ' ').replace(/\.\d+Z?$/u, 'Z');
421
+ const fileCount = Number(backup?.fileCount || 0);
422
+
423
+ return `
424
+ <div class="git-file-row">
425
+ <button
426
+ class="git-file-item"
427
+ type="button"
428
+ data-git-pull-backup-path="${escapeHtml(backup.summaryPath || '')}"
429
+ >
430
+ ${fileIconSvg()}
431
+ <span class="git-file-copy">
432
+ <span class="git-file-name">Pull backup ${escapeHtml(backup.id || '')}</span>
433
+ <span class="git-file-path">${escapeHtml(`${createdAt} · ${backup.branch || 'HEAD'} · ${fileCount} file${fileCount === 1 ? '' : 's'}`)}</span>
434
+ </span>
435
+ <span class="git-status-badge modified">BK</span>
436
+ </button>
437
+ </div>
438
+ `;
439
+ }
440
+
370
441
  renderFile(file) {
371
442
  const isActive = this.selection.path === file.path && this.selection.scope === file.scope;
372
443
  const dirPath = getPathDir(file.path);
@@ -449,6 +520,7 @@ export class GitPanelController {
449
520
  }
450
521
 
451
522
  const branch = this.status.branch ?? {};
523
+ const pullBackupsMarkup = this.renderPullBackupsSection();
452
524
  const sectionMarkup = (this.status.sections ?? [])
453
525
  .map((section) => this.renderSection(section))
454
526
  .filter(Boolean)
@@ -492,6 +564,7 @@ export class GitPanelController {
492
564
  </button>
493
565
  </div>
494
566
  </div>
567
+ ${pullBackupsMarkup}
495
568
  ${sectionMarkup || '<div class="git-panel-empty">No local changes</div>'}
496
569
  ${hasChanges ? `
497
570
  <div class="git-panel-footer">
@@ -2,6 +2,7 @@ export class OutlineController {
2
2
  constructor({
3
3
  mobileBreakpointQuery = window.matchMedia('(max-width: 768px)'),
4
4
  onNavigateToHeading,
5
+ onWillOpen,
5
6
  } = {}) {
6
7
  this.outlineOpen = false;
7
8
  this.activeHeadingFrame = null;
@@ -9,16 +10,36 @@ export class OutlineController {
9
10
  this.headings = [];
10
11
  this.pinnedHeadingId = null;
11
12
  this.onNavigateToHeading = onNavigateToHeading;
13
+ this.onWillOpen = onWillOpen;
12
14
  this.panel = document.getElementById('outlinePanel');
13
15
  this.navigation = document.getElementById('outlineNav');
14
16
  this.previewContainer = document.getElementById('previewContainer');
15
17
  this.toggleButton = document.getElementById('outlineToggle');
16
18
  this.mobileBreakpointQuery = mobileBreakpointQuery;
17
19
  this.handlePreviewScroll = () => this.scheduleActiveHeadingUpdate();
20
+ this.handleDocumentPointerDown = (event) => {
21
+ if (!this.outlineOpen) {
22
+ return;
23
+ }
24
+
25
+ const target = event.target;
26
+ if (
27
+ target instanceof Node
28
+ && (
29
+ this.panel?.contains(target)
30
+ || this.toggleButton?.contains(target)
31
+ )
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ this.close();
37
+ };
18
38
  }
19
39
 
20
40
  initialize() {
21
41
  this.toggleButton?.addEventListener('click', () => this.toggle());
42
+ document.addEventListener('pointerdown', this.handleDocumentPointerDown);
22
43
  }
23
44
 
24
45
  toggle() {
@@ -30,6 +51,10 @@ export class OutlineController {
30
51
  }
31
52
 
32
53
  setOpenState(nextState) {
54
+ if (nextState && !this.outlineOpen) {
55
+ this.onWillOpen?.();
56
+ }
57
+
33
58
  this.outlineOpen = nextState;
34
59
  this.panel?.classList.toggle('hidden', !this.outlineOpen);
35
60
  this.toggleButton?.classList.toggle('active', this.outlineOpen);
@@ -2265,6 +2265,10 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2265
2265
 
2266
2266
  /* Preview body (contains preview + outline side by side) */
2267
2267
  .preview-body {
2268
+ --preview-floating-sidebar-gap: var(--space-3);
2269
+ --preview-floating-comments-width: min(300px, 34vw);
2270
+ --preview-floating-outline-width: 220px;
2271
+ --preview-content-max-width: 720px;
2268
2272
  display: flex;
2269
2273
  flex: 1;
2270
2274
  overflow: hidden;
@@ -2298,6 +2302,12 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
2298
2302
  color: var(--color-text-muted);
2299
2303
  }
2300
2304
 
2305
+ @media (min-width: 769px) {
2306
+ .preview-content {
2307
+ padding-bottom: calc(var(--space-12) + 96px);
2308
+ }
2309
+ }
2310
+
2301
2311
  .preview-content.is-excalidraw-file-preview {
2302
2312
  display: flex;
2303
2313
  width: 100%;
@@ -3670,14 +3680,45 @@ body.plantuml-maximized-open::before {
3670
3680
 
3671
3681
  /* ===== BACKLINKS PANEL ===== */
3672
3682
 
3683
+ .backlinks-panel.hidden,
3684
+ .backlinks-panel-layer.hidden {
3685
+ display: none;
3686
+ }
3673
3687
  .backlinks-panel {
3688
+ min-width: 0;
3689
+ }
3690
+ .backlinks-panel-layer {
3691
+ position: absolute;
3692
+ left: max(
3693
+ var(--space-4),
3694
+ calc((100% - min(var(--preview-content-max-width), 100%)) / 2)
3695
+ );
3696
+ bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
3697
+ width: min(320px, calc(min(var(--preview-content-max-width), 100%) - var(--space-8)));
3698
+ z-index: 14;
3699
+ pointer-events: none;
3700
+ }
3701
+ .backlinks-panel-dock {
3702
+ display: flex;
3703
+ flex-direction: column-reverse;
3704
+ border: 1px solid var(--color-divider);
3705
+ border-radius: 18px;
3706
+ background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-offset) 16%);
3707
+ box-shadow: 0 14px 34px oklch(0 0 0 / 0.18);
3708
+ backdrop-filter: blur(16px);
3709
+ overflow: hidden;
3710
+ pointer-events: auto;
3711
+ transition: box-shadow 160ms ease, transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
3712
+ }
3713
+ .backlinks-panel-dock:hover {
3714
+ transform: translateY(-1px);
3715
+ box-shadow: 0 18px 40px oklch(0 0 0 / 0.2);
3716
+ }
3717
+ .backlinks-panel-inline {
3674
3718
  border-top: 1px solid var(--color-divider);
3675
3719
  margin-top: var(--space-6);
3676
3720
  padding: 0 var(--space-4);
3677
3721
  }
3678
- .backlinks-panel.hidden {
3679
- display: none;
3680
- }
3681
3722
  .backlinks-header {
3682
3723
  display: flex;
3683
3724
  align-items: center;
@@ -3693,6 +3734,10 @@ body.plantuml-maximized-open::before {
3693
3734
  .backlinks-header:hover {
3694
3735
  color: var(--color-text);
3695
3736
  }
3737
+ .backlinks-panel-dock .backlinks-header {
3738
+ padding: 11px 14px;
3739
+ background: color-mix(in srgb, var(--color-surface-offset) 76%, var(--color-bg) 24%);
3740
+ }
3696
3741
  .backlinks-header-empty {
3697
3742
  cursor: default;
3698
3743
  opacity: 0.5;
@@ -3709,15 +3754,20 @@ body.plantuml-maximized-open::before {
3709
3754
  }
3710
3755
  .backlinks-toggle {
3711
3756
  flex: 1;
3757
+ min-width: 0;
3758
+ overflow: hidden;
3759
+ text-overflow: ellipsis;
3760
+ white-space: nowrap;
3712
3761
  }
3713
3762
  .backlinks-count {
3714
3763
  font-size: var(--text-xs);
3715
3764
  font-weight: 600;
3716
3765
  color: var(--color-primary);
3717
- background: oklch(from var(--color-primary) l c h / 0.12);
3766
+ background: oklch(from var(--color-primary) l c h / 0.14);
3718
3767
  border-radius: var(--radius-full);
3719
- padding: 1px 8px;
3720
- min-width: 0;
3768
+ padding: 1px 7px;
3769
+ min-width: 22px;
3770
+ text-align: center;
3721
3771
  }
3722
3772
  .backlinks-count:empty {
3723
3773
  display: none;
@@ -3727,16 +3777,31 @@ body.plantuml-maximized-open::before {
3727
3777
  max-height: 0;
3728
3778
  transition: max-height 200ms ease;
3729
3779
  }
3730
- .backlinks-panel.expanded .backlinks-body {
3780
+ .backlinks-panel-inline.expanded .backlinks-body {
3731
3781
  max-height: 600px;
3732
3782
  overflow-y: auto;
3733
3783
  }
3784
+ .backlinks-panel-dock .backlinks-body {
3785
+ background: color-mix(in srgb, var(--color-surface) 94%, var(--color-surface-offset) 6%);
3786
+ }
3787
+ .backlinks-panel-dock.expanded .backlinks-body {
3788
+ max-height: min(44vh, 400px);
3789
+ overflow-y: auto;
3790
+ border-bottom: 1px solid color-mix(in srgb, var(--color-divider) 78%, var(--color-primary) 22%);
3791
+ }
3792
+ .backlinks-panel-dock.expanded {
3793
+ border-color: color-mix(in srgb, var(--color-divider) 72%, var(--color-primary) 28%);
3794
+ background: color-mix(in srgb, var(--color-surface) 90%, var(--color-surface-offset) 10%);
3795
+ }
3734
3796
  .backlinks-list {
3735
3797
  padding-bottom: var(--space-4);
3736
3798
  display: flex;
3737
3799
  flex-direction: column;
3738
3800
  gap: var(--space-2);
3739
3801
  }
3802
+ .backlinks-panel-dock .backlinks-list {
3803
+ padding: 10px;
3804
+ }
3740
3805
  .backlink-item {
3741
3806
  display: block;
3742
3807
  width: 100%;
@@ -3748,6 +3813,10 @@ body.plantuml-maximized-open::before {
3748
3813
  cursor: pointer;
3749
3814
  transition: border-color var(--transition-interactive), background var(--transition-interactive);
3750
3815
  }
3816
+ .backlinks-panel-dock .backlink-item {
3817
+ border-radius: 12px;
3818
+ background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-offset) 12%);
3819
+ }
3751
3820
  .backlink-item:hover {
3752
3821
  border-color: var(--color-primary);
3753
3822
  background: oklch(from var(--color-primary) l c h / 0.04);
@@ -3789,6 +3858,12 @@ body.plantuml-maximized-open::before {
3789
3858
  color: var(--color-text-faint);
3790
3859
  }
3791
3860
 
3861
+ @media (min-width: 769px) {
3862
+ .backlinks-panel-inline {
3863
+ display: none !important;
3864
+ }
3865
+ }
3866
+
3792
3867
  .outline-panel {
3793
3868
  width: 220px;
3794
3869
  flex-shrink: 0;
@@ -3889,6 +3964,9 @@ body.plantuml-maximized-open::before {
3889
3964
  .editor-layout[data-view="preview"] .preview-pane {
3890
3965
  flex: 1;
3891
3966
  }
3967
+ .editor-layout[data-view="preview"] .preview-body {
3968
+ --preview-content-max-width: 1080px;
3969
+ }
3892
3970
  .editor-layout[data-view="preview"] .preview-content {
3893
3971
  max-width: 1080px;
3894
3972
  }
@@ -3923,6 +4001,55 @@ body.plantuml-maximized-open::before {
3923
4001
  .editor-layout[data-view="preview"] .preview-content p:has(> .plantuml-shell:only-child) {
3924
4002
  max-width: none;
3925
4003
  }
4004
+ @media (min-width: 769px) {
4005
+ .preview-body > .comments-drawer,
4006
+ .preview-body > .outline-panel {
4007
+ position: absolute;
4008
+ top: var(--preview-floating-sidebar-gap);
4009
+ right: var(--preview-floating-sidebar-gap);
4010
+ bottom: var(--preview-floating-sidebar-gap);
4011
+ z-index: 20;
4012
+ overflow: hidden;
4013
+ border: 1px solid var(--color-divider);
4014
+ border-radius: calc(var(--radius-lg) + 2px);
4015
+ box-shadow: var(--shadow-lg);
4016
+ backdrop-filter: blur(14px);
4017
+ }
4018
+
4019
+ .preview-body > .comments-drawer {
4020
+ width: var(--preview-floating-comments-width);
4021
+ min-width: 240px;
4022
+ border-left: 1px solid var(--color-divider);
4023
+ }
4024
+
4025
+ .preview-body > .outline-panel {
4026
+ width: var(--preview-floating-outline-width);
4027
+ border-left: 1px solid var(--color-divider);
4028
+ }
4029
+
4030
+ .preview-body > .comments-drawer.hidden,
4031
+ .preview-body > .outline-panel.hidden {
4032
+ display: flex !important;
4033
+ opacity: 0;
4034
+ pointer-events: none;
4035
+ transform: translateX(12px);
4036
+ visibility: hidden;
4037
+ }
4038
+
4039
+ .preview-body > .comments-drawer:not(.hidden),
4040
+ .preview-body > .outline-panel:not(.hidden) {
4041
+ opacity: 1;
4042
+ transform: translateX(0);
4043
+ visibility: visible;
4044
+ }
4045
+
4046
+ .preview-body:has(> .outline-panel:not(.hidden)) > .comments-drawer:not(.hidden) {
4047
+ right: calc(
4048
+ var(--preview-floating-sidebar-gap) * 2
4049
+ + var(--preview-floating-outline-width)
4050
+ );
4051
+ }
4052
+ }
3926
4053
  .editor-layout[data-view="editor"] .editor-pane {
3927
4054
  flex: 1;
3928
4055
  }
@@ -4022,6 +4149,10 @@ body.plantuml-maximized-open::before {
4022
4149
  padding: var(--space-4);
4023
4150
  }
4024
4151
 
4152
+ .backlinks-panel-layer {
4153
+ display: none !important;
4154
+ }
4155
+
4025
4156
  .diff-toolbar {
4026
4157
  flex-wrap: wrap;
4027
4158
  padding: var(--space-2) var(--space-3);
@@ -1,5 +1,8 @@
1
- export function createGitRequestError(statusCode, message) {
1
+ export function createGitRequestError(statusCode, message, requestCode = null) {
2
2
  const error = new Error(message);
3
3
  error.statusCode = statusCode;
4
+ if (requestCode) {
5
+ error.requestCode = requestCode;
6
+ }
4
7
  return error;
5
8
  }
@@ -6,6 +6,7 @@ import { parseNameStatusOutput } from './parsers.js';
6
6
  import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
7
7
  import { GitStatusService } from './status-service.js';
8
8
  import { GitUntrackedFileService } from './untracked-files.js';
9
+ import { PullBackupStore } from '../persistence/pull-backup-store.js';
9
10
 
10
11
  export class GitService {
11
12
  constructor({
@@ -36,6 +37,7 @@ export class GitService {
36
37
  statusService: this.statusService,
37
38
  untrackedFileService: this.untrackedFileService,
38
39
  });
40
+ this.pullBackupStore = new PullBackupStore({ vaultDir });
39
41
  }
40
42
 
41
43
  async isGitRepo() {
@@ -55,6 +57,14 @@ export class GitService {
55
57
  return this.statusService.getStatus(options);
56
58
  }
57
59
 
60
+ async listPullBackups() {
61
+ if (!(await this.isGitRepo())) {
62
+ return [];
63
+ }
64
+
65
+ return this.pullBackupStore.listBackups();
66
+ }
67
+
58
68
  async stageFile(path) {
59
69
  const normalizedPath = normalizeRelativeGitPath(path);
60
70
  await this.commandRunner.execGit(['add', '-A', '--', normalizedPath]);
@@ -131,7 +141,45 @@ export class GitService {
131
141
  }
132
142
 
133
143
  const beforeRef = await this.getHeadRef();
134
- const output = await this.commandRunner.execGit(['pull', '--ff-only']);
144
+ await this.fetchUpstream(status.branch.upstream);
145
+
146
+ const targetRef = await this.resolveRef(status.branch.upstream);
147
+ if (!targetRef) {
148
+ throw createGitRequestError(409, 'Unable to resolve upstream branch for pull');
149
+ }
150
+
151
+ if (!(await this.canFastForwardTo(targetRef))) {
152
+ throw createGitRequestError(
153
+ 409,
154
+ 'Cannot pull because local and remote commits have diverged; fast-forward only pull is not possible.',
155
+ 'pull_diverged_ff_only',
156
+ );
157
+ }
158
+
159
+ const dirtyEntries = this.collectDirtyEntries(status);
160
+ const upstreamWorkspaceChange = await this.createWorkspaceChangeFromRefs(beforeRef, targetRef);
161
+ const overlappingEntries = this.findOverlappingDirtyEntries({
162
+ dirtyEntries,
163
+ workspaceChange: upstreamWorkspaceChange,
164
+ });
165
+
166
+ let pullBackup = null;
167
+ if (overlappingEntries.length > 0) {
168
+ pullBackup = await this.backupAndClearOverlappingEntries({
169
+ beforeRef,
170
+ branchName: status.branch?.name ?? null,
171
+ entries: overlappingEntries,
172
+ targetRef,
173
+ });
174
+ }
175
+
176
+ let output;
177
+ try {
178
+ output = await this.commandRunner.execGit(['pull', '--ff-only', '--autostash']);
179
+ } catch (error) {
180
+ throw await this.classifyPullError(error);
181
+ }
182
+
135
183
  const afterRef = await this.getHeadRef();
136
184
  this.invalidateStatusCache();
137
185
  return {
@@ -139,6 +187,7 @@ export class GitService {
139
187
  beforeRef,
140
188
  ok: true,
141
189
  output: output.trim(),
190
+ pullBackup,
142
191
  workspaceChange: await this.createWorkspaceChangeFromRefs(beforeRef, afterRef),
143
192
  };
144
193
  }
@@ -202,6 +251,171 @@ export class GitService {
202
251
  }
203
252
  }
204
253
 
254
+ async resolveRef(ref) {
255
+ try {
256
+ const output = await this.commandRunner.execGit(['rev-parse', ref]);
257
+ return output.trim() || null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ async fetchUpstream(upstreamRef) {
264
+ const remoteName = String(upstreamRef ?? '').split('/')[0] || null;
265
+ await this.commandRunner.execGit(remoteName
266
+ ? ['fetch', '--prune', remoteName]
267
+ : ['fetch', '--prune']);
268
+ }
269
+
270
+ async canFastForwardTo(targetRef) {
271
+ const headRef = await this.getHeadRef();
272
+ if (!headRef || !targetRef || headRef === targetRef) {
273
+ return true;
274
+ }
275
+
276
+ try {
277
+ await this.commandRunner.execGit(['merge-base', '--is-ancestor', headRef, targetRef]);
278
+ return true;
279
+ } catch (error) {
280
+ if (error?.code === 1) {
281
+ return false;
282
+ }
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ collectDirtyEntries(status = {}) {
288
+ const entries = new Map();
289
+ for (const section of status.sections ?? []) {
290
+ for (const file of section.files ?? []) {
291
+ if (!file?.path) {
292
+ continue;
293
+ }
294
+
295
+ const existing = entries.get(file.path) ?? {
296
+ hasStagedChanges: false,
297
+ hasWorkingTreeChanges: false,
298
+ hasTrackedChanges: false,
299
+ isUntracked: false,
300
+ oldPath: null,
301
+ path: file.path,
302
+ touchPaths: new Set(),
303
+ };
304
+
305
+ existing.isUntracked = existing.isUntracked || file.scope === 'untracked';
306
+ existing.hasStagedChanges = existing.hasStagedChanges || file.scope === 'staged';
307
+ existing.hasWorkingTreeChanges = existing.hasWorkingTreeChanges || file.scope === 'working-tree';
308
+ existing.hasTrackedChanges = existing.hasTrackedChanges || file.scope === 'staged' || file.scope === 'working-tree';
309
+ if (!existing.oldPath && file.oldPath) {
310
+ existing.oldPath = file.oldPath;
311
+ }
312
+ existing.touchPaths.add(file.path);
313
+ if (file.oldPath) {
314
+ existing.touchPaths.add(file.oldPath);
315
+ }
316
+ entries.set(file.path, existing);
317
+ }
318
+ }
319
+
320
+ return Array.from(entries.values());
321
+ }
322
+
323
+ findOverlappingDirtyEntries({ dirtyEntries = [], workspaceChange = createEmptyWorkspaceChange() } = {}) {
324
+ const upstreamTouchedPaths = new Set([
325
+ ...(workspaceChange.changedPaths ?? []),
326
+ ...(workspaceChange.deletedPaths ?? []),
327
+ ...((workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry.oldPath, entry.newPath])),
328
+ ].filter(Boolean));
329
+
330
+ return dirtyEntries.filter((entry) => Array.from(entry.touchPaths).some((path) => upstreamTouchedPaths.has(path)));
331
+ }
332
+
333
+ async createPatchForEntry(entry, { cached = false } = {}) {
334
+ const pathspecs = Array.from(entry?.touchPaths ?? []).filter(Boolean);
335
+ if (pathspecs.length === 0) {
336
+ return null;
337
+ }
338
+
339
+ const output = await this.commandRunner.execGit([
340
+ 'diff',
341
+ '--binary',
342
+ '--find-renames',
343
+ ...(cached ? ['--cached'] : []),
344
+ '--',
345
+ ...pathspecs,
346
+ ]);
347
+
348
+ return output || null;
349
+ }
350
+
351
+ async backupAndClearOverlappingEntries({
352
+ beforeRef,
353
+ branchName = null,
354
+ entries = [],
355
+ targetRef = null,
356
+ } = {}) {
357
+ const backupEntries = await Promise.all(entries.map(async (entry) => ({
358
+ ...entry,
359
+ stagedPatchContent: entry.hasStagedChanges
360
+ ? await this.createPatchForEntry(entry, { cached: true })
361
+ : null,
362
+ worktreePatchContent: entry.hasWorkingTreeChanges
363
+ ? await this.createPatchForEntry(entry, { cached: false })
364
+ : null,
365
+ })));
366
+
367
+ const pullBackup = await this.pullBackupStore.createBackup({
368
+ branch: branchName,
369
+ entries: backupEntries,
370
+ headRef: beforeRef,
371
+ targetRef,
372
+ });
373
+
374
+ for (const entry of backupEntries) {
375
+ if (entry.isUntracked && !entry.hasTrackedChanges) {
376
+ await this.commandRunner.execGit(['clean', '-f', '--', entry.path]);
377
+ continue;
378
+ }
379
+
380
+ await this.restorePathToHead(entry.path);
381
+ }
382
+
383
+ return pullBackup;
384
+ }
385
+
386
+ async restorePathToHead(path) {
387
+ const normalizedPath = normalizeRelativeGitPath(path);
388
+ const sourceRef = 'HEAD';
389
+ const existsOnSource = await this.pathExistsAtRef(sourceRef, normalizedPath);
390
+
391
+ if (existsOnSource) {
392
+ await this.commandRunner.execGit(['restore', '--source', sourceRef, '--staged', '--worktree', '--', normalizedPath]);
393
+ return;
394
+ }
395
+
396
+ await this.commandRunner.execGit(['rm', '-f', '--ignore-unmatch', '--', normalizedPath]);
397
+ await this.commandRunner.execGit(['clean', '-f', '--', normalizedPath]);
398
+ }
399
+
400
+ async hasConflictedStatus() {
401
+ const status = await this.getStatus({ force: true });
402
+ return (status.sections ?? [])
403
+ .flatMap((section) => section.files ?? [])
404
+ .some((file) => file?.status === 'conflicted');
405
+ }
406
+
407
+ async classifyPullError(error) {
408
+ if (await this.hasConflictedStatus()) {
409
+ return createGitRequestError(
410
+ 409,
411
+ 'Pull applied remote updates, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.',
412
+ 'pull_conflicted_after_autostash',
413
+ );
414
+ }
415
+
416
+ return error;
417
+ }
418
+
205
419
  async createWorkspaceChangeFromRefs(beforeRef, afterRef) {
206
420
  if (!afterRef || beforeRef === afterRef) {
207
421
  return createEmptyWorkspaceChange();