@markjaquith/agency 3.13.0 → 3.14.0
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 +1 -1
- package/src/commands/sync.ts +2 -1
- package/src/services/SyncService.ts +189 -26
package/package.json
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -138,6 +138,7 @@ Options:
|
|
|
138
138
|
|
|
139
139
|
Sync may materialize declared repositories and unambiguous missing checkouts,
|
|
140
140
|
adopt legacy repositories with portable origins, record a uniquely matched PR,
|
|
141
|
-
|
|
141
|
+
adopt a recorded PR's retargeted base or renamed head branch, and mark merged
|
|
142
|
+
work done. Dirty, stale, or
|
|
142
143
|
conflicting checkouts are always left unresolved.
|
|
143
144
|
`
|
|
@@ -58,7 +58,12 @@ interface RegisteredWorktree {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
interface SyncChange {
|
|
61
|
-
readonly kind:
|
|
61
|
+
readonly kind:
|
|
62
|
+
| "materialize-workspace"
|
|
63
|
+
| "adopt-pr-branch"
|
|
64
|
+
| "adopt-pr-base"
|
|
65
|
+
| "record-pr"
|
|
66
|
+
| "mark-done"
|
|
62
67
|
readonly target: string
|
|
63
68
|
readonly message: string
|
|
64
69
|
readonly status: "planned" | "applied"
|
|
@@ -162,14 +167,34 @@ interface PullRequestQuery {
|
|
|
162
167
|
| undefined
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
const canonicalRecord = (record: PullRequestRecord | null) =>
|
|
171
|
+
record
|
|
172
|
+
? JSON.stringify(
|
|
173
|
+
Object.entries(record)
|
|
174
|
+
.filter(([, value]) => value !== undefined)
|
|
175
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
176
|
+
)
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const samePullRequestRecord = (
|
|
180
|
+
current: PullRequestRecord,
|
|
181
|
+
existing: PullRequestRecord | null,
|
|
182
|
+
) => canonicalRecord(current) === canonicalRecord(existing)
|
|
183
|
+
|
|
184
|
+
const belongsToWritableRepository = (
|
|
185
|
+
current: PullRequestRecord,
|
|
186
|
+
remoteRepository: string,
|
|
187
|
+
) =>
|
|
188
|
+
current.headRepository?.toLowerCase() === remoteRepository.toLowerCase() &&
|
|
189
|
+
current.baseRepository?.toLowerCase() === current.repository.toLowerCase()
|
|
190
|
+
|
|
165
191
|
const mergedPullRequestFromGitHub = (
|
|
166
192
|
data: ExecutionData,
|
|
167
193
|
query: PullRequestQuery | undefined,
|
|
168
194
|
details: readonly PullRequestRecord[] | undefined,
|
|
169
195
|
) => {
|
|
170
196
|
if (!query?.result || query.result.exitCode !== 0 || !details) return null
|
|
171
|
-
const
|
|
172
|
-
const matches = existing
|
|
197
|
+
const matches = data.pr
|
|
173
198
|
? details
|
|
174
199
|
: details.filter(
|
|
175
200
|
(item) =>
|
|
@@ -177,18 +202,10 @@ const mergedPullRequestFromGitHub = (
|
|
|
177
202
|
)
|
|
178
203
|
if (matches.length !== 1) return null
|
|
179
204
|
const current = matches[0]!
|
|
180
|
-
|
|
181
|
-
current.
|
|
182
|
-
current
|
|
183
|
-
|
|
184
|
-
current.headBranch !== data.branch ||
|
|
185
|
-
current.baseRepository?.toLowerCase() !==
|
|
186
|
-
current.repository.toLowerCase() ||
|
|
187
|
-
current.baseBranch !== data.base
|
|
188
|
-
) {
|
|
189
|
-
return null
|
|
190
|
-
}
|
|
191
|
-
return current
|
|
205
|
+
return current.merged === true &&
|
|
206
|
+
belongsToWritableRepository(current, query.remoteRepository)
|
|
207
|
+
? current
|
|
208
|
+
: null
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
const mutateExecution = (
|
|
@@ -650,6 +667,22 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
650
667
|
{ concurrency: 8 },
|
|
651
668
|
)
|
|
652
669
|
|
|
670
|
+
const branchOwners = new Map<string, string>()
|
|
671
|
+
const branchKey = (repo: string, branch: string) =>
|
|
672
|
+
JSON.stringify([repo, branch])
|
|
673
|
+
for (const task of allTaskRecords) {
|
|
674
|
+
if ("repo" in task.data)
|
|
675
|
+
branchOwners.set(
|
|
676
|
+
branchKey(task.data.repo, task.data.branch),
|
|
677
|
+
`task:${task.id}`,
|
|
678
|
+
)
|
|
679
|
+
for (const phase of documents.phasesByTask.get(task.id) ?? [])
|
|
680
|
+
branchOwners.set(
|
|
681
|
+
branchKey(phase.data.repo, phase.data.branch),
|
|
682
|
+
`phase:${task.id}/${phase.id}`,
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
|
|
653
686
|
for (const record of records.sort((a, b) =>
|
|
654
687
|
a.key.localeCompare(b.key),
|
|
655
688
|
)) {
|
|
@@ -662,6 +695,126 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
662
695
|
const githubDetails = githubResponse?.ok
|
|
663
696
|
? githubResponse.details
|
|
664
697
|
: undefined
|
|
698
|
+
let renamedFrom: string | null = null
|
|
699
|
+
let adoptionConflict = false
|
|
700
|
+
const recordedPr =
|
|
701
|
+
!config.delivery && data.pr ? githubDetails?.[0] : undefined
|
|
702
|
+
if (
|
|
703
|
+
query &&
|
|
704
|
+
recordedPr?.headBranch &&
|
|
705
|
+
recordedPr.baseBranch &&
|
|
706
|
+
belongsToWritableRepository(recordedPr, query.remoteRepository) &&
|
|
707
|
+
(recordedPr.headBranch !== data.branch ||
|
|
708
|
+
recordedPr.baseBranch !== data.base)
|
|
709
|
+
) {
|
|
710
|
+
const previous = { branch: data.branch, base: data.base }
|
|
711
|
+
const adopted = {
|
|
712
|
+
branch: recordedPr.headBranch,
|
|
713
|
+
base: recordedPr.baseBranch,
|
|
714
|
+
}
|
|
715
|
+
const repositoryPath = join(root, "repos", data.repo)
|
|
716
|
+
let renameLocal = false
|
|
717
|
+
if (adopted.branch !== previous.branch) {
|
|
718
|
+
const owner = branchOwners.get(
|
|
719
|
+
branchKey(data.repo, adopted.branch),
|
|
720
|
+
)
|
|
721
|
+
const [previousLocal, adoptedLocal] = yield* Effect.all([
|
|
722
|
+
backend.resolveRevision(
|
|
723
|
+
repositoryPath,
|
|
724
|
+
`refs/heads/${previous.branch}`,
|
|
725
|
+
),
|
|
726
|
+
backend.resolveRevision(
|
|
727
|
+
repositoryPath,
|
|
728
|
+
`refs/heads/${adopted.branch}`,
|
|
729
|
+
),
|
|
730
|
+
])
|
|
731
|
+
if (owner && owner !== record.key) {
|
|
732
|
+
adoptionConflict = true
|
|
733
|
+
unresolved.push({
|
|
734
|
+
kind: "pr-branch-conflict",
|
|
735
|
+
target: record.key,
|
|
736
|
+
message: `Recorded PR head '${adopted.branch}' is declared by '${owner}'`,
|
|
737
|
+
action:
|
|
738
|
+
"Correct the recorded PR URL or the other declaration",
|
|
739
|
+
})
|
|
740
|
+
} else if (previousLocal && adoptedLocal) {
|
|
741
|
+
adoptionConflict = true
|
|
742
|
+
unresolved.push({
|
|
743
|
+
kind: "pr-branch-conflict",
|
|
744
|
+
target: record.key,
|
|
745
|
+
message: `Cannot rename local branch '${previous.branch}' to recorded PR head '${adopted.branch}' because it already exists`,
|
|
746
|
+
action: `Reconcile or delete local branch '${adopted.branch}' manually`,
|
|
747
|
+
})
|
|
748
|
+
} else {
|
|
749
|
+
renameLocal = previousLocal !== null
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (!adoptionConflict && apply) {
|
|
753
|
+
const renameBranch = (from: string, to: string) =>
|
|
754
|
+
runExternal(["git", "branch", "-m", from, to], {
|
|
755
|
+
cwd: repositoryPath,
|
|
756
|
+
})
|
|
757
|
+
const renamed = renameLocal
|
|
758
|
+
? yield* renameBranch(previous.branch, adopted.branch)
|
|
759
|
+
: null
|
|
760
|
+
if (renamed && renamed.exitCode !== 0) {
|
|
761
|
+
adoptionConflict = true
|
|
762
|
+
unresolved.push({
|
|
763
|
+
kind: "pr-branch-rename-failed",
|
|
764
|
+
target: record.key,
|
|
765
|
+
message: `Could not rename local branch '${previous.branch}' to '${adopted.branch}': ${commandErrorSummary(renamed.stderr, "git branch failed")}`,
|
|
766
|
+
action: "Rename the local branch manually",
|
|
767
|
+
})
|
|
768
|
+
} else {
|
|
769
|
+
const updated = yield* mutateExecution(root, record, revision, {
|
|
770
|
+
...data,
|
|
771
|
+
...adopted,
|
|
772
|
+
}).pipe(
|
|
773
|
+
Effect.tapError(() =>
|
|
774
|
+
renamed
|
|
775
|
+
? renameBranch(adopted.branch, previous.branch)
|
|
776
|
+
: Effect.void,
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
data = updated.data
|
|
780
|
+
revision = updated.revision
|
|
781
|
+
const cached = registeredByRepository.get(repositoryPath)
|
|
782
|
+
for (const [index, item] of (cached ?? []).entries()) {
|
|
783
|
+
if (item.branch === `refs/heads/${previous.branch}`)
|
|
784
|
+
cached![index] = {
|
|
785
|
+
...item,
|
|
786
|
+
branch: `refs/heads/${adopted.branch}`,
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
} else if (!adoptionConflict) {
|
|
791
|
+
data = { ...data, ...adopted }
|
|
792
|
+
if (renameLocal) renamedFrom = previous.branch
|
|
793
|
+
}
|
|
794
|
+
if (!adoptionConflict) {
|
|
795
|
+
if (adopted.branch !== previous.branch) {
|
|
796
|
+
branchOwners.delete(branchKey(data.repo, previous.branch))
|
|
797
|
+
branchOwners.set(
|
|
798
|
+
branchKey(data.repo, adopted.branch),
|
|
799
|
+
record.key,
|
|
800
|
+
)
|
|
801
|
+
changes.push({
|
|
802
|
+
kind: "adopt-pr-branch",
|
|
803
|
+
target: record.key,
|
|
804
|
+
message: `Adopt pull request head '${adopted.branch}' (was '${previous.branch}')`,
|
|
805
|
+
status: apply ? "applied" : "planned",
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
if (adopted.base !== previous.base) {
|
|
809
|
+
changes.push({
|
|
810
|
+
kind: "adopt-pr-base",
|
|
811
|
+
target: record.key,
|
|
812
|
+
message: `Adopt pull request base '${adopted.base}' (was '${previous.base}')`,
|
|
813
|
+
status: apply ? "applied" : "planned",
|
|
814
|
+
})
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
665
818
|
const remoteMergedPr = config.delivery
|
|
666
819
|
? null
|
|
667
820
|
: mergedPullRequestFromGitHub(data, query, githubDetails)
|
|
@@ -838,7 +991,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
838
991
|
action: "Review and preserve or discard local changes manually",
|
|
839
992
|
})
|
|
840
993
|
}
|
|
841
|
-
if (
|
|
994
|
+
if (
|
|
995
|
+
atPath &&
|
|
996
|
+
branchRef &&
|
|
997
|
+
atPath.branch !== branchRef &&
|
|
998
|
+
!(renamedFrom && atPath.branch === `refs/heads/${renamedFrom}`)
|
|
999
|
+
) {
|
|
842
1000
|
unresolved.push({
|
|
843
1001
|
kind: "wrong-branch",
|
|
844
1002
|
target: record.key,
|
|
@@ -1022,21 +1180,26 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1022
1180
|
if (githubDetails) {
|
|
1023
1181
|
current = githubDetails[0]!
|
|
1024
1182
|
pr = current
|
|
1025
|
-
if (
|
|
1026
|
-
current.headRepository?.toLowerCase() !==
|
|
1027
|
-
remoteRepository.toLowerCase() ||
|
|
1028
|
-
current.headBranch !== data.branch ||
|
|
1029
|
-
current.baseRepository?.toLowerCase() !==
|
|
1030
|
-
current.repository.toLowerCase() ||
|
|
1031
|
-
current.baseBranch !== data.base
|
|
1032
|
-
) {
|
|
1183
|
+
if (!belongsToWritableRepository(current, remoteRepository)) {
|
|
1033
1184
|
prConflict = true
|
|
1034
1185
|
unresolved.push({
|
|
1035
1186
|
kind: "pr-repository-conflict",
|
|
1036
1187
|
target: record.key,
|
|
1037
|
-
message: `Recorded PR
|
|
1188
|
+
message: `Recorded PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
|
|
1038
1189
|
action: "Correct the declaration or recorded PR URL",
|
|
1039
1190
|
})
|
|
1191
|
+
} else if (
|
|
1192
|
+
current.headBranch !== data.branch ||
|
|
1193
|
+
current.baseBranch !== data.base
|
|
1194
|
+
) {
|
|
1195
|
+
prConflict = true
|
|
1196
|
+
if (!adoptionConflict)
|
|
1197
|
+
unresolved.push({
|
|
1198
|
+
kind: "pr-branch-conflict",
|
|
1199
|
+
target: record.key,
|
|
1200
|
+
message: `Recorded PR head '${current.headBranch ?? "unknown"}' or base '${current.baseBranch ?? "unknown"}' could not be adopted`,
|
|
1201
|
+
action: "Correct the declaration or recorded PR URL",
|
|
1202
|
+
})
|
|
1040
1203
|
}
|
|
1041
1204
|
}
|
|
1042
1205
|
} else {
|
|
@@ -1092,7 +1255,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1092
1255
|
}
|
|
1093
1256
|
}
|
|
1094
1257
|
|
|
1095
|
-
if (current &&
|
|
1258
|
+
if (current && !samePullRequestRecord(current, existing)) {
|
|
1096
1259
|
if (apply) {
|
|
1097
1260
|
const recorded = yield* mutateExecution(root, record, revision, {
|
|
1098
1261
|
...data,
|