Overview
Static code analysis (SCA) embodies the "shift left" principle by moving quality control earlier in the development lifecycle. The implementation of pre-commit hooks in our project will automate formatting and quality checks at the commit stage-the earliest possible point-rather than during later review or testing phases.
Challenges
A large legacy Android project with multiple development squad typically faces several key challenges that impact code quality and development efficiency:
- Team Structure: Large team consist of several squad, could easily lead to inconsistent coding styles and practices
- Mixed Architecture: Ongoing transition from legacy Architecture to newer architecture, creating dual coding paradigms within the codebase
- Inconsistent Standards: Variable adherence to code conventions across teams, resulting in style inconsistencies
- Inefficient Reviews: Significant code review time spent on trivial formatting issues rather than substantive code improvements
- Growing Technical Debt: Accumulation of preventable quality issues that could be addressed through automated analysis These challenges collectively slow development velocity, reduce code maintainability, and increase the cost of future development efforts.
The Plan
Pre-Commit Hooks
We will implement pre-commit hooks that automatically run before code is committed to our repository. These hooks will:
- Focus only on new or modified code to minimize disruption
- Format Kotlin code using ktlint
- Analyze code quality using detekt
- Give feedbacks for comitted code that do not meet our defined standards (Can be configured to prevent commits)
The git pre-commit hook, protects against committing code containing lint violations. From the perspective of code reviewing it is important that the code is already formatted in the style of the project.
Incremental Approach
To avoid disrupting ongoing development, our implementation will:
- Only analyze new and modified code
- Apply a threshold limit (e.g., files with fewer than X lines of code)
- Gradually improve code quality without requiring a complete codebase overhaul
Extensible Architecture
The pre-commit system will be designed with extensibility in mind:
- Each tool (ktlint, detekt, etc.) will have its own independent shell script
- A primary pre-commit hook will orchestrate these individual scripts
- New analysis tools can be added with minimal changes to the core system
Implementation Details
Tool Selection
ktlint (Docs)
ktlint will enforce consistent Kotlin code formatting based on the official Kotlin style guide, addressing issues such as:
- Indentation and spacing
- Line wrapping and line length
- Naming conventions
- Import ordering
We'll use kotlinter gradle plugin to add ktlint to our project
detekt (Docs)
detekt will perform deeper code analysis to identify:
- Code smells
- Complexity issues
- Potential bugs
- Performance concerns
- Architecture violations
Pre-Commit Hook Architecture
How It Works
-
Main Pre-Commit Script (scripts/pre-commit.sh):
- Controls the entire pre-commit checking process
- Runs each code analysis tool one after another
- Decides if the commit can proceed based on tool results
- Installed automatically through Gradle
-
File Selection Script (scripts/get-staged-files.sh):
- Finds Kotlin files that have been changed
- Filters out files with too many changes
- Ensures consistent file selection for all tools
-
Tool-Specific Scripts (scripts/tools/):
- Separate scripts for each tool (e.g., run_ktlint.sh , run_detekt.sh )
- Uses file selection script to choose which files to check
- Reports if files pass or fail the checks
-
Configuration Files:
- .editorconfig: Defines code formatting rules (ktlint)
- .detekt/config.yml: Sets up code quality analysis (detekt)
- Follows standard locations for easy finding
-
Gradle Setup (enablePreCommit task):
- Easy one-command setup for developers
- Automatically installs the pre-commit hook
- Works the same on all developer machines
Advantages
- Easy to add new code analysis tools
- Consistent file checking across tools
- Simple to understand configuration
- Quick setup for new team members
- All scripts are tracked in the project repository
┌───────────────────────────────────────┐
│ │
│ Project Repository │
│ ├── .git/ │
│ │ └── hooks/ │
│ │ └── pre-commit ◄────────┐ │
│ │ │ │
│ ├── .editorconfig │ │
│ ├── .detekt/ │ │
│ │ └── config.yml │ │
│ │ │ │
│ ├── scripts/ │ │
│ │ ├── pre-commit.sh ──────────┘ │
│ │ │ (moved by Gradle task) │
│ │ │ │
│ │ ├── get-staged-files.sh │
│ │ │ │
│ │ └── tools/ │
│ │ ├── run_ktlint.sh (format) │
│ │ ├── run_detekt.sh │
│ │ └── run_future_tool.sh │
│ │ │
│ ├── build.gradle.kts │
│ │ (contains enablePreCommit task) │
│ │ │
│ └── [project files] │
│ │
└───────────────────────────────────────┘
Flow:
- Developer runs Gradle task: ./gradlew enablePreCommit (only once)
- Task copies scripts/pre-commit.sh to .git/hooks/pre-commit (only once)
- When developer commits, .git/hooks/pre-commit executes
- pre-commit calls individual tool scripts in scripts/tools/
- Each tool script uses scripts/get-staged-files.sh to identify relevant files to analyze
The Codes
Config files
.detekt/config.yml
build:
maxIssues: 0
excludeCorrectable: true
weights:
comments: 0
config:
validation: true
warningsAsErrors: false
checkExhaustiveness: false
coroutines:
active: true
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunWithFlowReturnType:
active: true
exceptions:
active: true
PrintStackTrace:
active: true
formatting:
active: true
android: true
autoCorrect: true
AnnotationOnSeparateLine:
active: true
autoCorrect: true
style:
active: true
ReturnCount:
active: true
max: 4
MaxLineLength:
active: true
severity: warning
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: true
MagicNumber:
active: true
ignoreNumbers: [ '-1', '0', '1', '2', '100' , '00' ]
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
AlsoCouldBeApply:
active: true
CollapsibleIfStatements:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: true
UnnecessaryLet:
active: true
UseIfEmptyOrIfBlank:
active: true
UseIfInsteadOfWhen:
active: true
UseLet:
active: true
complexity:
active: true
TooManyFunctions:
active: false
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignorePrivate: false
ignoreOverridden: false
naming:
active: true
InvalidPackageDeclaration:
active: true
NoNameShadowing:
active: true
potential-bugs:
active: true
CastToNullableType:
active: true
EqualsAlwaysReturnsTrueOrFalse:
active: true
LateinitUsage:
active: true
ignoreAnnotated:
- Inject
NullableToStringCall:
active: true
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnsafeCallOnNullableType:
active: true
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
.editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
tab_width = 4
[*.{kt,kts}]
ktlint_code_style = android_studio
ktlint_ignore_back_ticked_identifier = true
# Note that rules in any ruleset other than the standard ruleset will need to be prefixed
# by the ruleset identifier.
ktlint_standard_filename = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_no-empty-file = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_colon-spacing = disabled
ktlint_standard_no-blank-line-before-rbrace = disabled
ktlint_standard_function-expression-body = disabled
ij_kotlin_imports_layout=*,java.**,javax.**,kotlin.**,^ # default IntelliJ IDEA style, same as alphabetical, but with "java", "javax", "kotlin" and alias imports in the end of the imports list
# Enforce the Kotlin Modern code style
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
# These rules conflict with ktlint, so they need to be disabled
ij_kotlin_allow_trailing_comma = false
ij_kotlin_allow_trailing_comma_on_call_site = false
Scripts
scripts/get-staged-files.sh
#!/bin/bash
# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
LIGHT_GREY='\033[0;37m'
NC='\033[0m' # No Color
# Configuration: maximum number of lines for modified files
# Files with more lines will be excluded, but new files are always included
MAX_LINES_THRESHOLD=750
# Get list of staged Kotlin files, but separate modified and added files
MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M HEAD | grep ".*kt$")
ADDED_FILES=$(git diff --cached --name-only --diff-filter=A HEAD | grep ".*kt$")
# Filter out modified files that exceed the line threshold
FILTERED_FILES=""
for FILE in $MODIFIED_FILES; do
LINE_COUNT=$(wc -l < "$FILE")
if [ "$LINE_COUNT" -le "$MAX_LINES_THRESHOLD" ]; then
FILTERED_FILES+="$FILE"$'\n'
else
echo -e "${LIGHT_GREY}Skipping $FILE (${LINE_COUNT} lines exceed threshold of $MAX_LINES_THRESHOLD)${NC}" >&2
fi
done
# Add all new files to the list
for FILE in $ADDED_FILES; do
FILTERED_FILES+="$FILE"$'\n'
done
# Output the filtered files (one per line), removing any trailing newline
echo -n "$FILTERED_FILES" | grep -v "^$"
scripts/pre-commit.sh
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
LIGHT_BLUE='\033[1;34m'
NC='\033[0m' # No Color
# Get git root directory
GIT_ROOT=$(git rev-parse --show-toplevel)
# Helper function to run scripts and handle their exit codes
run_script() {
local script_path=$1
local script_name=$(basename "$script_path")
# Check if script exists
if [ ! -f "$script_path" ]; then
echo -e "${YELLOW}Warning: ${script_name} not found, skipping...${NC}"
echo
return 0
fi
echo -e "${LIGHT_BLUE}Running ${script_name%.*}.sh${NC}"
$script_path
local result=$?
echo
if [ $result -ne 0 ]; then
echo -e "${RED}${script_name%.*} failed! Abort commit...${NC}"
exit 1
fi
}
# Make scripts executable if they exist
[ -f "$GIT_ROOT/scripts/get-staged-files.sh" ] && chmod +x "$GIT_ROOT/scripts/get-staged-files.sh"
[ -d "$GIT_ROOT/scripts/tools" ] && find "$GIT_ROOT/scripts/tools" -type f -name "*.sh" -exec chmod +x {} \;
SCRIPTS=(
"$GIT_ROOT/scripts/tools/run_ktlint.sh"
"$GIT_ROOT/scripts/tools/run_detekt.sh"
# Add more scripts here
)
# Run each script
for script in "${SCRIPTS[@]}"; do
run_script "$script"
done
echo -e "${GREEN}Pre-commit process completed! (Some checks may have been skipped)${NC}"
exit 0
scripts/tools/run_detekt.sh
#!/bin/bash
# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Running code analysis...${NC}"
GIT_ROOT=$(git rev-parse --show-toplevel)
# Get staged files from the separate scripts
STAGED_FILES=$(./scripts/get-staged-files.sh)
# Exit if no files to analyze after filtering
if [ -z "$STAGED_FILES" ]; then
echo -e "${YELLOW}No eligible Kotlin files found${NC}"
exit 0
fi
# Count total files
TOTAL_FILES=$(echo "$STAGED_FILES" | wc -l)
# Display files to be analyzed
echo -e "${YELLOW}Found $TOTAL_FILES Kotlin files to analyze:${NC}"
echo " - ${STAGED_FILES//$'\n'/$'\n - '}"
echo
# Convert to comma-separated list for gradle using platform-agnostic approach
FILES_ARG=""
while IFS= read -r FILE; do
if [ -n "$FILE" ]; then # Skip empty lines
if [ -z "$FILES_ARG" ]; then
FILES_ARG="$FILE"
else
FILES_ARG="$FILES_ARG,$FILE"
fi
fi
done <<< "$STAGED_FILES"
# Run detekt on staged files
echo -e "${YELLOW}Running detekt on staged files...${NC}"
./gradlew -q detekt -PinputFiles="$FILES_ARG"
DETEKT_STATUS=$?
if [ $DETEKT_STATUS -eq 0 ]; then
echo -e "${GREEN}Detekt check passed successfully!${NC}"
exit 0
else
echo -e "${RED}Detekt check failed with exit code $DETEKT_STATUS${NC}"
# Show report location and try to open it
REPORT_PATH="$GIT_ROOT/build/reports/detekt/detekt.html"
echo -e "${YELLOW}Detekt report available at:${NC}"
echo -e "$REPORT_PATH"
# Try to open the report based on OS
case "$(uname -s)" in
Darwin*) # macOS
open "$REPORT_PATH" 2>/dev/null || true
;;
Linux*) # Linux
xdg-open "$REPORT_PATH" 2>/dev/null || true
;;
MINGW*|CYGWIN*) # Windows
start "$REPORT_PATH" 2>/dev/null || true
;;
esac
exit 0
fi
scripts/tools/run_ktlint.sh
#!/bin/bash
# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Running format check...${NC}"
# Get staged files from the separate scripts
STAGED_FILES=$(./scripts/get-staged-files.sh)
# Check if any Kotlin files are staged after filtering
if [ -z "$STAGED_FILES" ]; then
echo -e "${YELLOW}No Kotlin files to format${NC}"
exit 0
fi
# Count total files
TOTAL_FILES=$(echo "$STAGED_FILES" | wc -l)
# Display files to be formatted
echo -e "${YELLOW}Found $TOTAL_FILES Kotlin files to format:${NC}"
echo " - ${STAGED_FILES//$'\n'/$'\n - '}"
echo
# Convert to comma-separated list for gradle using platform-agnostic approach
FILES_ARG=""
while IFS= read -r FILE; do
if [ -n "$FILE" ]; then # Skip empty lines
if [ -z "$FILES_ARG" ]; then
FILES_ARG="$FILE"
else
FILES_ARG="$FILES_ARG,$FILE"
fi
fi
done <<< "$STAGED_FILES"
# Run ktlint format on staged files
echo -e "${YELLOW}Running ktlint format on staged files...${NC}"
./gradlew -q formatKotlinMain -PinputFiles="$FILES_ARG"
# Add back the formatted files to staging
echo -e "${YELLOW}Adding formatted files back to git staging...${NC}"
for FILE in $STAGED_FILES; do
if [ -f "$FILE" ]; then
git add "$FILE"
fi
done
echo -e "${GREEN}Formatting complete!${NC}"
add the following in you build.gradle.kts (:app module)
/**
* Static code analysis configuration.
*/
allprojects {
apply(plugin = "org.jmailen.kotlinter")
kotlinter {
ktlintVersion = "1.5.0"
ignoreFormatFailures = true
ignoreLintFailures = false
reporters = arrayOf("html")
}
afterEvaluate {
tasks.withType<LintTask>().configureEach {
source = project.files(project.resolveInputFiles()).asFileTree
}
tasks.withType<FormatTask>().configureEach {
source = project.files(project.resolveInputFiles()).asFileTree
}
}
}
dependencies {
detektPlugins(libs.detekt.formatting)
}
detekt {
buildUponDefaultConfig = false // preconfigure defaults
allRules = false // activate all available (even unstable) rules
config.setFrom(project.rootProject.file(".detekt/config.yml"))
baseline = project.rootProject.file("$.detekt/baseline.xml")
}
tasks.withType<Detekt>().configureEach {
reports {
sarif.required.set(false)
xml.required.set(false)
md.required.set(false)
txt.required.set(false)
html.required.set(true)
}
source = project.files(project.resolveInputFiles()).asFileTree
}
tasks.register("enablePreCommitCheck") {
group = "git hooks"
description = "Installs the pre-commit hook for ktlint and detekt checks"
doLast {
// Get the source and destination files
val sourceFile = project.rootProject.file("scripts/pre-commit.sh")
val hooksDir = project.rootProject.file(".git/hooks")
val targetFile = hooksDir.resolve("pre-commit")
// Create hooks directory if it doesn't exist
hooksDir.mkdirs()
// Copy the file
if (sourceFile.exists()) {
sourceFile.copyTo(targetFile, overwrite = true)
// Make it executable
targetFile.setExecutable(true)
println("Pre-commit hook installed successfully!")
println("Location: ${targetFile.absolutePath}")
} else {
throw GradleException("Source file not found: ${sourceFile.absolutePath}")
}
}
}
tasks.register("disablePreCommitCheck") {
group = "git hooks"
description = "Remove the pre-commit hook for ktlint and detekt checks"
doLast {
val precommitFile = project.rootProject.file(".git/hooks/pre-commit")
if (precommitFile.exists()) {
precommitFile.delete()
println("Pre-commit hook has been removed")
} else {
println("No Pre-commit hook detected")
}
}
}
fun Project.resolveInputFiles(): Set<File> {
if (!hasProperty("inputFiles")) {
return emptySet()
}
val filesList = property("inputFiles").toString()
.split(",")
.filter { it.isNotBlank() }
.mapNotNull { path ->
projectDir.resolve(path).takeIf { it.exists() }
?: rootDir.resolve(path).takeIf { it.exists() }
}
.toSet()
if (filesList.isEmpty()) {
throw GradleException("No valid files found from 'inputFiles' property.")
}
return filesList
}
Usage
- Sync gradle
- Run
:enablePreCommitChecktask ( ./gradlew :enablePreCommitCheck ) just once - Done, the SCA tools will be triggered each time we push committed changes
Expected Benefits
Implementing pre-commit hooks for static code analysis will deliver several important advantages to our development process:
- Streamlined Code Reviews: Developers and reviewers can focus on logic, architecture, and functionality rather than formatting issues
- Consistent Code Style: Automatic enforcement of formatting standards across all modified files ensures stylistic uniformity regardless of which developer made the changes
- Reduced Technical Debt: Preventing quality issues before they enter the codebase eliminates the accumulation of small but impactful technical debt
- Earlier Issue Detection: Identifying potential problems at commit time rather than during code review or testing accelerates the feedback loop
- Improved Developer Experience: Developers receive immediate feedback on their code quality without waiting for review comments
- Enhanced Maintainability: Consistent formatting and adherence to quality standards make the codebase more navigable and understandable
- Smoother Onboarding: New team members can quickly adapt to project standards with automated guidance
These benefits directly address typical challenges in a large legacy android project that has multiple distributed development squad.


Top comments (1)
Thanks for diving into such a niche, often overlooked topic—legacy Android + SCA setup details like this are super valuable.