| ... | ... | @@ -1007,6 +1007,279 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1007 | 1007 | assert.notInclude(error.message, secret); |
| 1008 | 1008 | assert.notProperty(error, "args"); |
| 1009 | 1009 | assert.notProperty(error, "stderr"); |
| 1010 | assert.notProperty(error, "reason"); |
| 1011 | }), |
| 1012 | ); |
| 1013 | |
| 1014 | it.effect("names the branch conflict behind a failed worktree add", () => |
| 1015 | Effect.gen(function* () { |
| 1016 | const parent = yield* makeTmpDir(); |
| 1017 | const pathService = yield* Path.Path; |
| 1018 | const cwd = pathService.join(parent, "repo"); |
| 1019 | const fileSystem = yield* FileSystem.FileSystem; |
| 1020 | yield* fileSystem.makeDirectory(cwd); |
| 1021 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1022 | yield* initRepoWithCommit(cwd); |
| 1023 | const firstWorktree = pathService.join(parent, "first"); |
| 1024 | yield* git(cwd, ["worktree", "add", firstWorktree, "-b", "shared-branch"]); |
| 1025 | |
| 1026 | const error = yield* driver |
| 1027 | .execute({ |
| 1028 | operation: "GitVcsDriver.test.worktreeAddConflict", |
| 1029 | cwd, |
| 1030 | args: ["worktree", "add", pathService.join(parent, "second"), "shared-branch"], |
| 1031 | env: { LC_ALL: "C" }, |
| 1032 | }) |
| 1033 | .pipe(Effect.flip); |
| 1034 | |
| 1035 | assert.deepInclude(error, { |
| 1036 | _tag: "GitCommandError", |
| 1037 | operation: "GitVcsDriver.test.worktreeAddConflict", |
| 1038 | reason: "branch_checked_out_in_worktree", |
| 1039 | }); |
| 1040 | assert.include(error.message, "(branch_checked_out_in_worktree)"); |
| 1041 | assert.notInclude(error.message, firstWorktree); |
| 1042 | }), |
| 1043 | ); |
| 1044 | |
| 1045 | it.effect("names a missing repository behind a failed command", () => |
| 1046 | Effect.gen(function* () { |
| 1047 | const cwd = yield* makeTmpDir(); |
| 1048 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1049 | |
| 1050 | const error = yield* driver |
| 1051 | .execute({ |
| 1052 | operation: "GitVcsDriver.test.notARepository", |
| 1053 | cwd, |
| 1054 | args: ["rev-parse", "--abbrev-ref", "HEAD"], |
| 1055 | env: { LC_ALL: "C" }, |
| 1056 | }) |
| 1057 | .pipe(Effect.flip); |
| 1058 | |
| 1059 | assert.deepInclude(error, { |
| 1060 | _tag: "GitCommandError", |
| 1061 | operation: "GitVcsDriver.test.notARepository", |
| 1062 | reason: "not_a_repository", |
| 1063 | }); |
| 1064 | assert.include(error.message, "(not_a_repository)"); |
| 1065 | }), |
| 1066 | ); |
| 1067 | |
| 1068 | it.effect("appends the reason tag to a caller-supplied detail", () => |
| 1069 | Effect.gen(function* () { |
| 1070 | const parent = yield* makeTmpDir(); |
| 1071 | const pathService = yield* Path.Path; |
| 1072 | const cwd = pathService.join(parent, "repo"); |
| 1073 | const fileSystem = yield* FileSystem.FileSystem; |
| 1074 | yield* fileSystem.makeDirectory(cwd); |
| 1075 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1076 | yield* initRepoWithCommit(cwd); |
| 1077 | yield* git(cwd, [ |
| 1078 | "worktree", |
| 1079 | "add", |
| 1080 | pathService.join(parent, "taken"), |
| 1081 | "-b", |
| 1082 | "taken-branch", |
| 1083 | ]); |
| 1084 | |
| 1085 | const error = yield* driver |
| 1086 | .createWorktree({ |
| 1087 | cwd, |
| 1088 | refName: "taken-branch", |
| 1089 | path: pathService.join(parent, "second"), |
| 1090 | }) |
| 1091 | .pipe(Effect.flip); |
| 1092 | |
| 1093 | assert.equal(error.detail, "git worktree add failed"); |
| 1094 | assert.include(error.message, "git worktree add failed (branch_checked_out_in_worktree)"); |
| 1095 | }), |
| 1096 | ); |
| 1097 | |
| 1098 | it.effect.each([ |
| 1099 | { |
| 1100 | label: "a refused key", |
| 1101 | sshMessage: "git@example.invalid: Permission denied (publickey).", |
| 1102 | expectedReason: "authentication_failed" as const, |
| 1103 | expectedText: "(authentication_failed)", |
| 1104 | }, |
| 1105 | { |
| 1106 | label: "a refused key among several methods", |
| 1107 | sshMessage: "git@example.invalid: Permission denied (publickey,password).", |
| 1108 | expectedReason: "authentication_failed" as const, |
| 1109 | expectedText: "(authentication_failed)", |
| 1110 | }, |
| 1111 | { |
| 1112 | label: "a refused keyboard-interactive attempt", |
| 1113 | sshMessage: "git@example.invalid: Permission denied (keyboard-interactive).", |
| 1114 | expectedReason: "authentication_failed" as const, |
| 1115 | expectedText: "(authentication_failed)", |
| 1116 | }, |
| 1117 | { |
| 1118 | label: "a rejected password", |
| 1119 | sshMessage: "Permission denied, please try again.", |
| 1120 | expectedReason: "authentication_failed" as const, |
| 1121 | expectedText: "(authentication_failed)", |
| 1122 | }, |
| 1123 | { |
| 1124 | label: "an untrusted host key", |
| 1125 | sshMessage: "Host key verification failed.", |
| 1126 | expectedReason: "host_key_unverified" as const, |
| 1127 | expectedText: "(host_key_unverified)", |
| 1128 | }, |
| 1129 | ])( |
| 1130 | "names $label rather than an unreachable remote", |
| 1131 | ({ sshMessage, expectedReason, expectedText }) => |
| 1132 | Effect.gen(function* () { |
| 1133 | const parent = yield* makeTmpDir(); |
| 1134 | const pathService = yield* Path.Path; |
| 1135 | const cwd = pathService.join(parent, "repo"); |
| 1136 | const fileSystem = yield* FileSystem.FileSystem; |
| 1137 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1138 | yield* fileSystem.makeDirectory(cwd); |
| 1139 | yield* initRepoWithCommit(cwd); |
| 1140 | yield* git(cwd, ["remote", "add", "origin", "git@example.invalid:owner/repo.git"]); |
| 1141 | // Stands in for ssh, which prints its refusal unprefixed and leaves |
| 1142 | // git to add only a generic "could not read" line after it. |
| 1143 | const fakeSsh = pathService.join(parent, "fake-ssh"); |
| 1144 | yield* writeTextFile( |
| 1145 | parent, |
| 1146 | "fake-ssh", |
| 1147 | `#!/bin/sh\necho "${sshMessage}" >&2\nexit 255\n`, |
| 1148 | ); |
| 1149 | yield* fileSystem.chmod(fakeSsh, 0o755); |
| 1150 | |
| 1151 | const error = yield* driver |
| 1152 | .execute({ |
| 1153 | operation: "GitVcsDriver.test.sshRefusal", |
| 1154 | cwd, |
| 1155 | args: ["fetch", "origin"], |
| 1156 | env: { LC_ALL: "C", GIT_SSH_COMMAND: fakeSsh }, |
| 1157 | }) |
| 1158 | .pipe(Effect.flip); |
| 1159 | |
| 1160 | assert.deepInclude(error, { reason: expectedReason }); |
| 1161 | assert.include(error.message, expectedText); |
| 1162 | }), |
| 1163 | ); |
| 1164 | |
| 1165 | it.effect("does not read a filesystem permission error as an ssh refusal", () => |
| 1166 | Effect.gen(function* () { |
| 1167 | const parent = yield* makeTmpDir(); |
| 1168 | const pathService = yield* Path.Path; |
| 1169 | const cwd = pathService.join(parent, "repo"); |
| 1170 | const fileSystem = yield* FileSystem.FileSystem; |
| 1171 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1172 | yield* fileSystem.makeDirectory(cwd); |
| 1173 | yield* initRepoWithCommit(cwd); |
| 1174 | yield* git(cwd, ["remote", "add", "origin", "git@example.invalid:owner/repo.git"]); |
| 1175 | const fakeSsh = pathService.join(parent, "fake-ssh"); |
| 1176 | yield* writeTextFile( |
| 1177 | parent, |
| 1178 | "fake-ssh", |
| 1179 | '#!/bin/sh\necho "fatal: cannot open backup file: Permission denied (os error 13)" >&2\nexit 255\n', |
| 1180 | ); |
| 1181 | yield* fileSystem.chmod(fakeSsh, 0o755); |
| 1182 | |
| 1183 | const error = yield* driver |
| 1184 | .execute({ |
| 1185 | operation: "GitVcsDriver.test.osPermission", |
| 1186 | cwd, |
| 1187 | args: ["fetch", "origin"], |
| 1188 | env: { LC_ALL: "C", GIT_SSH_COMMAND: fakeSsh }, |
| 1189 | }) |
| 1190 | .pipe(Effect.flip); |
| 1191 | |
| 1192 | assert.notInclude(error.message, "(authentication_failed)"); |
| 1193 | }), |
| 1194 | ); |
| 1195 | |
| 1196 | it.effect("does not read remote hook output as a git failure reason", () => |
| 1197 | Effect.gen(function* () { |
| 1198 | const parent = yield* makeTmpDir(); |
| 1199 | const pathService = yield* Path.Path; |
| 1200 | const remote = pathService.join(parent, "origin.git"); |
| 1201 | const cwd = pathService.join(parent, "work"); |
| 1202 | const fileSystem = yield* FileSystem.FileSystem; |
| 1203 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1204 | yield* fileSystem.makeDirectory(cwd); |
| 1205 | yield* initRepoWithCommit(cwd); |
| 1206 | yield* git(cwd, ["init", "--bare", remote]); |
| 1207 | yield* git(cwd, ["remote", "add", "origin", remote]); |
| 1208 | // Git prefixes everything the server says with `remote:`, so a remote |
| 1209 | // hook can put arbitrary text in front of the classifier. |
| 1210 | const preReceive = pathService.join(remote, "hooks", "pre-receive"); |
| 1211 | yield* writeTextFile( |
| 1212 | remote, |
| 1213 | "hooks/pre-receive", |
| 1214 | '#!/bin/sh\necho "authentication failed" >&2\necho "Permission denied (publickey)." >&2\necho "Host key verification failed." >&2\nexit 1\n', |
| 1215 | ); |
| 1216 | yield* fileSystem.chmod(preReceive, 0o755); |
| 1217 | |
| 1218 | const error = yield* driver |
| 1219 | .execute({ |
| 1220 | operation: "GitVcsDriver.test.remoteHookOutput", |
| 1221 | cwd, |
| 1222 | args: ["push", "origin", "HEAD"], |
| 1223 | env: { LC_ALL: "C" }, |
| 1224 | }) |
| 1225 | .pipe(Effect.flip); |
| 1226 | |
| 1227 | assert.notProperty(error, "reason"); |
| 1228 | assert.notInclude(error.message, "(authentication_failed)"); |
| 1229 | }), |
| 1230 | ); |
| 1231 | |
| 1232 | it.effect("does not read hook output as a git failure reason", () => |
| 1233 | Effect.gen(function* () { |
| 1234 | const parent = yield* makeTmpDir(); |
| 1235 | const pathService = yield* Path.Path; |
| 1236 | const remote = pathService.join(parent, "origin.git"); |
| 1237 | const cwd = pathService.join(parent, "work"); |
| 1238 | const fileSystem = yield* FileSystem.FileSystem; |
| 1239 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1240 | yield* fileSystem.makeDirectory(cwd); |
| 1241 | yield* initRepoWithCommit(cwd); |
| 1242 | yield* git(cwd, ["init", "--bare", remote]); |
| 1243 | yield* git(cwd, ["remote", "add", "origin", remote]); |
| 1244 | yield* writeTextFile( |
| 1245 | cwd, |
| 1246 | ".git/hooks/pre-push", |
| 1247 | '#!/bin/sh\necho "authentication failed" >&2\nexit 1\n', |
| 1248 | ); |
| 1249 | yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-push"), 0o755); |
| 1250 | |
| 1251 | const error = yield* driver |
| 1252 | .execute({ |
| 1253 | operation: "GitVcsDriver.test.hookOutput", |
| 1254 | cwd, |
| 1255 | args: ["push", "origin", "HEAD"], |
| 1256 | env: { LC_ALL: "C" }, |
| 1257 | }) |
| 1258 | .pipe(Effect.flip); |
| 1259 | |
| 1260 | assert.notProperty(error, "reason"); |
| 1261 | assert.notInclude(error.message, "(authentication_failed)"); |
| 1262 | }), |
| 1263 | ); |
| 1264 | |
| 1265 | it.effect("leaves a tag collision unclassified rather than calling it a path", () => |
| 1266 | Effect.gen(function* () { |
| 1267 | const cwd = yield* makeTmpDir(); |
| 1268 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 1269 | yield* initRepoWithCommit(cwd); |
| 1270 | yield* git(cwd, ["tag", "v1"]); |
| 1271 | |
| 1272 | const error = yield* driver |
| 1273 | .execute({ |
| 1274 | operation: "GitVcsDriver.test.tagCollision", |
| 1275 | cwd, |
| 1276 | args: ["tag", "v1"], |
| 1277 | env: { LC_ALL: "C" }, |
| 1278 | }) |
| 1279 | .pipe(Effect.flip); |
| 1280 | |
| 1281 | assert.notProperty(error, "reason"); |
| 1282 | assert.notInclude(error.message, "(path_already_exists)"); |
| 1010 | 1283 | }), |
| 1011 | 1284 | ); |
| 1012 | 1285 | |