Skip to content

Commit c8bdf95

Browse files
author
Hamed Soleimani
committed
fix(amazonq): archives now preserve file premissions and amazon q can edit nvm/gradle files
1 parent 370ed03 commit c8bdf95

File tree

4 files changed

+156
-60
lines changed

4 files changed

+156
-60
lines changed

plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevSessionContextTest.kt

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ import org.mockito.kotlin.whenever
1313
import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext
1414
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevTestBase
1515
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.FeatureDevService
16+
import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule
17+
import software.aws.toolkits.jetbrains.utils.rules.addFileToModule
18+
import java.util.zip.ZipFile
19+
20+
class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTestFixtureRule()) {
21+
22+
private fun addFilesToProjectModule(vararg path: String) {
23+
val module = projectRule.module
24+
path.forEach { projectRule.fixture.addFileToModule(module, it, it) }
25+
}
1626

17-
class FeatureDevSessionContextTest : FeatureDevTestBase() {
1827
@Rule
1928
@JvmField
2029
val ruleChain = RuleChain(projectRule, disposableRule)
@@ -40,13 +49,64 @@ class FeatureDevSessionContextTest : FeatureDevTestBase() {
4049
fun testWithValidFile() {
4150
val ktFile = mock<VirtualFile>()
4251
whenever(ktFile.extension).thenReturn("kt")
52+
whenever(ktFile.path).thenReturn("code.kt")
4353
assertTrue(featureDevSessionContext.isFileExtensionAllowed(ktFile))
4454
}
4555

4656
@Test
4757
fun testWithInvalidFile() {
4858
val txtFile = mock<VirtualFile>()
4959
whenever(txtFile.extension).thenReturn("txt")
60+
whenever(txtFile.path).thenReturn("file.txt")
5061
assertFalse(featureDevSessionContext.isFileExtensionAllowed(txtFile))
5162
}
63+
64+
@Test
65+
fun testAllowedFilePath() {
66+
val allowedPaths = listOf("build.gradle", "gradle.properties", ".mvn/wrapper/maven-wrapper.properties")
67+
allowedPaths.forEach({
68+
val txtFile = mock<VirtualFile>()
69+
whenever(txtFile.path).thenReturn(it)
70+
whenever(txtFile.extension).thenReturn(it.split(".").last())
71+
assertTrue(featureDevSessionContext.isFileExtensionAllowed(txtFile))
72+
})
73+
}
74+
75+
@Test
76+
fun testZipProject() {
77+
addFilesToProjectModule(
78+
".gradle/cached.jar",
79+
"src/MyClass.java",
80+
"gradlew",
81+
"gradlew.bat",
82+
"README.md",
83+
"settings.gradle",
84+
"build.gradle",
85+
"gradle/wrapper/gradle-wrapper.properties",
86+
)
87+
88+
val zipResult = featureDevSessionContext.getProjectZip()
89+
val zipPath = zipResult.payload.path
90+
91+
val zippedFiles = mutableSetOf<String>()
92+
ZipFile(zipPath).use { zipFile ->
93+
for (entry in zipFile.entries()) {
94+
if (!entry.name.endsWith("/")) {
95+
zippedFiles.add(entry.name)
96+
}
97+
}
98+
}
99+
100+
val expectedFiles = setOf(
101+
"src/MyClass.java",
102+
"gradlew",
103+
"gradlew.bat",
104+
"README.md",
105+
"settings.gradle",
106+
"build.gradle",
107+
"gradle/wrapper/gradle-wrapper.properties",
108+
)
109+
110+
assertTrue(zippedFiles == expectedFiles)
111+
}
52112
}

plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererProjectCodeScanTest.kt

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
113113
val testModule2 = projectRule.fixture.addModule("testModule2")
114114
testCs = projectRule.fixture.addFileToModule(
115115
testModule,
116-
"/Test.cs",
116+
"Test.cs",
117117
"""
118118
using Utils;
119119
using Helpers.Helper;
@@ -131,7 +131,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
131131

132132
utilsCs = projectRule.fixture.addFileToModule(
133133
testModule,
134-
"/Utils.cs",
134+
"Utils.cs",
135135
"""
136136
public static class Utils
137137
{
@@ -157,7 +157,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
157157

158158
helperCs = projectRule.fixture.addFileToModule(
159159
testModule,
160-
"/Helpers/Helper.cs",
160+
"Helpers/Helper.cs",
161161
"""
162162
public static class Helper
163163
{
@@ -201,7 +201,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
201201

202202
helpGo = projectRule.fixture.addFileToModule(
203203
testModule,
204-
"/help.go",
204+
"help.go",
205205
"""
206206
package main
207207
@@ -217,7 +217,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
217217

218218
utilsJs = projectRule.fixture.addFileToModule(
219219
testModule,
220-
"/utils.js",
220+
"utils.js",
221221
"""
222222
function add(num1, num2) {
223223
return num1 + num2;
@@ -247,7 +247,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
247247

248248
testJson = projectRule.fixture.addFileToModule(
249249
testModule,
250-
"/helpers/test3Json.json",
250+
"helpers/test3Json.json",
251251
"""
252252
{
253253
"AWSTemplateFormatVersion": "2010-09-09",
@@ -303,7 +303,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
303303

304304
helperPy = projectRule.fixture.addFileToModule(
305305
testModule,
306-
"/helpers/helper.py",
306+
"helpers/helper.py",
307307
"""
308308
from helpers import helper as h
309309
def subtract(num1, num2)
@@ -319,13 +319,13 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
319319
totalSize += helperPy.length
320320
totalLines += helperPy.toNioPath().toFile().readLines().size
321321

322-
readMeMd = projectRule.fixture.addFileToModule(testModule, "/ReadMe.md", "### Now included").virtualFile
322+
readMeMd = projectRule.fixture.addFileToModule(testModule, "ReadMe.md", "### Now included").virtualFile
323323
totalSize += readMeMd.length
324324
totalLines += readMeMd.toNioPath().toFile().readLines().size
325325

326326
testTf = projectRule.fixture.addFileToModule(
327327
testModule2,
328-
"/testTf.tf",
328+
"testTf.tf",
329329
"""
330330
# Create example resource for three S3 buckets using for_each, where the bucket prefix are in variable with list containing [prod, staging, dev]
331331
@@ -345,7 +345,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
345345

346346
testYaml = projectRule.fixture.addFileToModule(
347347
testModule2,
348-
"/testYaml.yaml",
348+
"testYaml.yaml",
349349
"""
350350
AWSTemplateFormatVersion: "2010-09-09"
351351
@@ -371,7 +371,7 @@ class CodeWhispererProjectCodeScanTest : CodeWhispererCodeScanTestBase(PythonCod
371371

372372
// Adding gitignore file and gitignore file member for testing.
373373
// The tests include the markdown file but not these two files.
374-
projectRule.fixture.addFileToProject("/.gitignore", "node_modules\n.idea\n.vscode\n.DS_Store").virtualFile
375-
projectRule.fixture.addFileToProject("test.idea", "ref: refs/heads/main")
374+
projectRule.fixture.addFileToProject(".gitignore", "node_modules\n.idea\n.vscode\n.DS_Store").virtualFile
375+
projectRule.fixture.addFileToProject(".idea/ref", "ref: refs/heads/main")
376376
}
377377
}

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/FeatureDevSessionContext.kt

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,26 @@ import kotlinx.coroutines.launch
1616
import kotlinx.coroutines.runBlocking
1717
import kotlinx.coroutines.withContext
1818
import org.apache.commons.codec.digest.DigestUtils
19-
import software.aws.toolkits.core.utils.outputStream
20-
import software.aws.toolkits.core.utils.putNextEntry
21-
import software.aws.toolkits.jetbrains.core.coroutines.EDT
19+
import org.apache.commons.io.FileUtils
2220
import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext
2321
import software.aws.toolkits.jetbrains.services.telemetry.ALLOWED_CODE_EXTENSIONS
2422
import software.aws.toolkits.resources.AwsCoreBundle
2523
import software.aws.toolkits.telemetry.AmazonqTelemetry
2624
import java.io.File
2725
import java.io.FileInputStream
26+
import java.net.URI
27+
import java.nio.file.FileSystem
28+
import java.nio.file.FileSystems
2829
import java.nio.file.Files
2930
import java.nio.file.Path
31+
import java.nio.file.Paths
32+
import java.nio.file.StandardCopyOption
3033
import java.util.Base64
31-
import java.util.zip.ZipOutputStream
34+
import java.util.UUID
3235
import kotlin.coroutines.coroutineContext
3336
import kotlin.io.path.Path
37+
import kotlin.io.path.createParentDirectories
38+
import kotlin.io.path.getPosixFilePermissions
3439
import kotlin.io.path.relativeTo
3540

3641
interface RepoSizeError {
@@ -41,41 +46,45 @@ class RepoSizeLimitError(override val message: String) : RuntimeException(), Rep
4146
class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Long? = null) {
4247
// TODO: Need to correct this class location in the modules going further to support both amazonq and codescan.
4348

44-
private val ignorePatterns = setOf(
45-
"\\.aws-sam",
46-
"\\.svn",
47-
"\\.hg/?",
48-
"\\.rvm",
49-
"\\.git/?",
50-
"\\.gitignore",
51-
"\\.project",
52-
"\\.gem",
53-
"/\\.idea/?",
54-
"\\.zip$",
55-
"\\.bin$",
56-
"\\.png$",
57-
"\\.jpg$",
58-
"\\.svg$",
59-
"\\.pyc$",
60-
"/license\\.txt$",
61-
"/License\\.txt$",
62-
"/LICENSE\\.txt$",
63-
"/license\\.md$",
64-
"/License\\.md$",
65-
"/LICENSE\\.md$",
66-
"node_modules/?",
67-
"build/?",
68-
"dist/?"
69-
).map { Regex(it) }
49+
private val additionalGitIgnoreRules = setOf(
50+
".aws-sam",
51+
".gem",
52+
".git",
53+
".gitignore",
54+
".gradle",
55+
".hg",
56+
".idea",
57+
".project",
58+
".rvm",
59+
".svn",
60+
"*.zip",
61+
"*.bin",
62+
"*.png",
63+
"*.jpg",
64+
"*.svg",
65+
"*.pyc",
66+
"license.txt",
67+
"License.txt",
68+
"LICENSE.txt",
69+
"license.md",
70+
"License.md",
71+
"LICENSE.md",
72+
"node_modules",
73+
"build",
74+
"dist"
75+
)
7076

7177
// well known source files that do not have extensions
7278
private val wellKnownSourceFiles = setOf(
7379
"Dockerfile",
74-
"Dockerfile.build"
80+
"Dockerfile.build",
81+
"gradlew",
82+
"mvnw"
7583
)
7684

7785
// projectRoot: is the directory where the project is located when selected to open a project.
7886
val projectRoot = project.guessProjectDir() ?: error("Cannot guess base directory for project ${project.name}")
87+
private val projectRootPath = Paths.get(projectRoot.path) ?: error("Can not find project root path")
7988

8089
// selectedSourceFolder": is the directory selected in replacement of the root, this happens when the project is too big to bundle for uploading.
8190
private var _selectedSourceFolder = projectRoot
@@ -85,10 +94,10 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
8594
init {
8695
ignorePatternsWithGitIgnore = try {
8796
buildList {
88-
addAll(ignorePatterns)
89-
parseGitIgnore().mapNotNull { pattern ->
90-
runCatching { Regex(pattern) }.getOrNull()
91-
}.let { addAll(it) }
97+
addAll(additionalGitIgnoreRules.map { convertGitIgnorePatternToRegex(it) })
98+
addAll(parseGitIgnore())
99+
}.mapNotNull { pattern ->
100+
runCatching { Regex(pattern) }.getOrNull()
92101
}
93102
} catch (e: Exception) {
94103
emptyList()
@@ -108,7 +117,6 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
108117
fun isFileExtensionAllowed(file: VirtualFile): Boolean {
109118
// if it is a directory, it is allowed
110119
if (file.isDirectory) return true
111-
112120
val extension = file.extension ?: return false
113121
return ALLOWED_CODE_EXTENSIONS.contains(extension)
114122
}
@@ -122,7 +130,13 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
122130
// this method reads like something a JS dev would write and doesn't do what the author thinks
123131
val deferredResults = ignorePatternsWithGitIgnore.map { pattern ->
124132
withContext(coroutineContext) {
125-
async { pattern.containsMatchIn(path) }
133+
// avoid partial match (pattern.containsMatchIn) since it causes us matching files
134+
// against folder patterns. (e.g. settings.gradle ignored by .gradle rule!)
135+
// we convert the glob rules to regex, add a trailing /* to all rules and then match
136+
// entries against them by adding a trailing /.
137+
// TODO: Add unit tests for gitignore matching
138+
val relative = if (path.startsWith(projectRootPath.toString())) Paths.get(path).relativeTo(projectRootPath) else path
139+
async { pattern.matches("$relative/") }
126140
}
127141
}
128142

@@ -132,7 +146,7 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
132146
return deferredResults.any { it.await() }
133147
}
134148

135-
private fun wellKnown(file: VirtualFile): Boolean = wellKnownSourceFiles.contains(file.name)
149+
fun wellKnown(file: VirtualFile): Boolean = wellKnownSourceFiles.contains(file.name)
136150

137151
suspend fun zipFiles(projectRoot: VirtualFile): File = withContext(getCoroutineBgContext()) {
138152
val files = mutableListOf<VirtualFile>()
@@ -187,18 +201,36 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
187201
}
188202
}
189203

190-
createTemporaryZipFileAsync { zipOutput ->
204+
val zipFilePath = createTemporaryZipFileAsync { zipfs ->
205+
val isPosix = FileSystems.getDefault().supportedFileAttributeViews().contains("posix")
191206
filesToIncludeFlow.collect { file ->
192-
val relativePath = Path(file.path).relativeTo(projectRoot.toNioPath())
193-
zipOutput.putNextEntry(relativePath.toString(), Path(file.path))
207+
if (!file.isDirectory) {
208+
val externalFilePath = Path(file.path)
209+
val relativePath = Path(file.path).relativeTo(projectRootPath)
210+
val zipfsPath = zipfs.getPath("/$relativePath")
211+
runBlocking {
212+
zipfsPath.createParentDirectories()
213+
Files.copy(externalFilePath, zipfsPath, StandardCopyOption.REPLACE_EXISTING)
214+
if (isPosix) {
215+
Files.setAttribute(zipfsPath, "zip:permissions", externalFilePath.getPosixFilePermissions())
216+
}
217+
}
218+
}
194219
}
195220
}
221+
zipFilePath
196222
}.toFile()
197223

198-
private suspend fun createTemporaryZipFileAsync(block: suspend (ZipOutputStream) -> Unit): Path = withContext(EDT) {
199-
val file = Files.createTempFile(null, ".zip")
200-
ZipOutputStream(file.outputStream()).use { zipOutput -> block(zipOutput) }
201-
file
224+
private suspend fun createTemporaryZipFileAsync(block: suspend (FileSystem) -> Unit): Path = withContext(getCoroutineBgContext()) {
225+
// Don't use Files.createTempFile since the file must not be created for ZipFS to work
226+
val tempFilePath: Path = Paths.get(FileUtils.getTempDirectory().absolutePath, "${UUID.randomUUID()}.zip")
227+
val uri = URI.create("jar:${tempFilePath.toUri()}")
228+
val env = hashMapOf("create" to "true")
229+
val zipfs = FileSystems.newFileSystem(uri, env)
230+
zipfs.use {
231+
block(zipfs)
232+
}
233+
tempFilePath
202234
}
203235

204236
private fun parseGitIgnore(): Set<String> {
@@ -216,7 +248,7 @@ class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Lo
216248
private fun convertGitIgnorePatternToRegex(pattern: String): String = pattern
217249
.replace(".", "\\.")
218250
.replace("*", ".*")
219-
.let { if (it.endsWith("/")) "$it?" else it } // Handle directory-specific patterns by optionally matching trailing slash
251+
.let { if (it.endsWith("/")) "$it.*" else "$it/.*" } // Add a trailing /* to all patterns. (we add a trailing / to all files when matching)
220252

221253
var selectedSourceFolder: VirtualFile
222254
set(newRoot) {

0 commit comments

Comments
 (0)