Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# GitHub Personal Access Token (optional but recommended for higher API rate limits)
# Get one from: https://github.com/settings/tokens
GITHUB_TOKEN=your_github_token_here

# Comma-separated list of GitHub users/organizations to backup
# Example: SCHEDULED_USERS=octocat,github,spring-projects
SCHEDULED_USERS=octocat
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Build stage
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are skipped during the Docker build with -DskipTests. While this speeds up the build, it means the Docker image could contain code that hasn't been tested. Consider running tests during the build to ensure code quality, or document why tests are skipped (e.g., if tests require external dependencies not available during build).

Suggested change
RUN mvn clean package -DskipTests
RUN mvn clean package

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Added comments documenting that tests should be run in CI/CD pipeline before building the Docker image, and that tests are skipped to speed up image creation.


# Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# Install git (required for cloning repositories)
RUN apk add --no-cache git

# Copy the built jar from build stage
COPY --from=build /app/target/gh-backup-1.0.0.jar gh-backup.jar

# Create backup directory
RUN mkdir -p /backups

# Set default environment variables
ENV BACKUP_DIRECTORY=/backups
ENV GITHUB_TOKEN=""
ENV SCHEDULED_USERS=""

# Expose port for web mode (optional)
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EXPOSE directive is included for web mode compatibility but the daemon mode (the default for this Docker image) doesn't use the web server. This could be misleading to users who might expect port 8080 to be available. Consider either removing this line since the Dockerfile is specifically described as being for daemon mode, or add a comment clarifying that this is only used if the container is run in web mode with a different profile.

Suggested change
# Expose port for web mode (optional)
# EXPOSE is only used when running this image in web mode with a different profile;
# the default daemon mode does not listen on port 8080.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Added clarifying comments that EXPOSE 8080 is only used when running the container in web mode with -Dspring.profiles.active=web, and that the default daemon mode does not use this port.

EXPOSE 8080

# Create entrypoint script
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh

# Default to daemon mode with scheduled backups
ENTRYPOINT ["/app/docker-entrypoint.sh"]
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A tool built with Spring Boot to backup public GitHub repositories for specified
## Features

- **Web UI** for easy backup management through your browser
- **Daemon Mode** with Docker support for automatic daily backups
- Backup all public repositories from one or more GitHub users/organizations
- Clone new repositories or update existing ones
- **Interactive mode** for easier management and status viewing
Expand Down Expand Up @@ -32,6 +33,71 @@ The executable JAR will be created at `target/gh-backup-1.0.0.jar`

## Usage

### Daemon Mode with Docker (Automatic Daily Backups)

The easiest way to run automatic daily backups is using Docker. This will spin up a background service that backs up your configured repositories every 24 hours.

#### Quick Start with Docker Compose

1. Create a `.env` file (or copy from `.env.example`):
```bash
cp .env.example .env
```

2. Edit `.env` to configure your backups:
```bash
GITHUB_TOKEN=your_github_token_here
SCHEDULED_USERS=octocat,github,spring-projects
```

3. Start the daemon service:
```bash
docker-compose up -d
```

The service will:
- Run the first backup immediately
- Continue running in the background
- Automatically backup every 24 hours
- Persist backups to the `./backups` directory on your host machine

4. View logs:
```bash
docker-compose logs -f
```

5. Stop the service:
```bash
docker-compose down
```

#### Using Docker directly

Build the image:
```bash
docker build -t gh-backup .
```

Run the daemon:
```bash
docker run -d \
-e GITHUB_TOKEN=your_token \
-e SCHEDULED_USERS=octocat,github \
-v $(pwd)/backups:/backups \
--name gh-backup-daemon \
gh-backup
```

#### Running without Docker

You can also run daemon mode directly with Java:
```bash
java -Dspring.profiles.active=daemon \
-Dbackup.scheduled.users=octocat,github \
-Dbackup.directory=/path/to/backups \
-jar target/gh-backup-1.0.0.jar
```

### Web UI Mode (Recommended)

Start the web server:
Expand Down
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: '3.8'

Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docker Compose version '3.8' is outdated. As of Docker Compose v2, the version field is deprecated and ignored. Consider removing the version line entirely to use the latest Compose file format, or update to a more recent version if backward compatibility with older Docker Compose versions is required.

Suggested change
version: '3.8'

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Removed deprecated version: '3.8' field from docker-compose.yml to use the latest Compose file format.

services:
gh-backup-daemon:
build: .
container_name: gh-backup-daemon
restart: unless-stopped
environment:
# Set your GitHub personal access token here for higher API rate limits
- GITHUB_TOKEN=${GITHUB_TOKEN:-}

# Comma-separated list of GitHub users/organizations to backup
# Example: SCHEDULED_USERS=octocat,github,spring-projects
- SCHEDULED_USERS=${SCHEDULED_USERS:-}

# Optional: Custom backup directory inside container
- BACKUP_DIRECTORY=/backups
volumes:
# Mount a local directory to persist backups
- ./backups:/backups
# Keep container running
stdin_open: true
tty: true
13 changes: 13 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
set -e

# Build Java command with environment variables
JAVA_OPTS="-Dspring.profiles.active=daemon"
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=\"${BACKUP_DIRECTORY}\""

if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=\"${SCHEDULED_USERS}\""
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The double quotes around the environment variable expansion will be passed literally to Java, causing the property value to include quotes. This will result in Java receiving -Dbackup.directory="/backups" with quotes as part of the value. Remove the escaped quotes around the variable expansion.

Suggested change
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=\"${BACKUP_DIRECTORY}\""
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=\"${SCHEDULED_USERS}\""
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}"
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Removed escaped quotes around ${BACKUP_DIRECTORY} and ${SCHEDULED_USERS} to prevent quotes from being passed as part of the property values to Java.

Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The double quotes around the environment variable expansion will be passed literally to Java, causing the property value to include quotes. This will result in Java receiving -Dbackup.scheduled.users="value" with quotes as part of the value. Remove the escaped quotes around the variable expansion.

Suggested change
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=\"${BACKUP_DIRECTORY}\""
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=\"${SCHEDULED_USERS}\""
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}"
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Removed escaped quotes around environment variable expansions in docker-entrypoint.sh.

fi

# Execute the application
exec java $JAVA_OPTS -jar gh-backup.jar "$@"
Comment on lines +5 to +18
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entrypoint builds JAVA_OPTS as a string and then executes exec java $JAVA_OPTS ... unquoted. This causes word-splitting and enables option injection if BACKUP_DIRECTORY or SCHEDULED_USERS contains spaces or starts with something like -D.../-X.... It also breaks valid paths containing spaces. Build the argument list safely (e.g., using set -- java ... and appending quoted -D...=$VAR args, then exec "$@" -jar ...) so env values are treated as data, not additional flags.

Suggested change
JAVA_OPTS="-Dspring.profiles.active=daemon"
# Set backup directory if provided (defaults to /backups in Dockerfile ENV)
if [ -n "$BACKUP_DIRECTORY" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}"
fi
# Set scheduled users if provided
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}"
fi
# Execute the application
exec java $JAVA_OPTS -jar gh-backup.jar "$@"
set -- java "-Dspring.profiles.active=daemon"
# Set backup directory if provided (defaults to /backups in Dockerfile ENV)
if [ -n "$BACKUP_DIRECTORY" ]; then
set -- "$@" "-Dbackup.directory=${BACKUP_DIRECTORY}"
fi
# Set scheduled users if provided
if [ -n "$SCHEDULED_USERS" ]; then
set -- "$@" "-Dbackup.scheduled.users=${SCHEDULED_USERS}"
fi
# Execute the application
set -- "$@" -jar gh-backup.jar "$@"
exec "$@"

Copilot uses AI. Check for mistakes.
2 changes: 2 additions & 0 deletions src/main/java/com/github/backup/GhBackupApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @EnableScheduling annotation is now enabled globally for all profiles including CLI and web modes. This may cause unexpected scheduling behavior in modes where it's not needed. Consider making this conditional on the daemon profile by creating a separate configuration class annotated with @configuration, @EnableScheduling, and @Profile("daemon") instead of placing @EnableScheduling at the application level.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Created separate SchedulingConfiguration class with @Profile("daemon") to ensure scheduling is only enabled in daemon mode, preventing unexpected behavior in CLI and web modes.

public class GhBackupApplication {

public static void main(String[] args) {
Expand Down
87 changes: 87 additions & 0 deletions src/main/java/com/github/backup/ScheduledBackupService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.github.backup;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;

/**
* Service that runs scheduled backups at configured intervals.
* Enabled when backup.mode=daemon.
*/
@Service
@ConditionalOnProperty(name = "backup.mode", havingValue = "daemon")
public class ScheduledBackupService {

private static final long BACKUP_INTERVAL_MS = 86400000; // 24 hours in milliseconds
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backup interval is hardcoded to 24 hours (86400000 ms). Consider making this configurable via a property like backup.scheduled.interval.ms with this as the default value, to allow users to adjust the backup frequency without modifying code or rebuilding the container.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Added backup.scheduled.interval.ms property (default: 86400000 = 24 hours) to allow users to configure backup frequency without code changes. Example usage: -Dbackup.scheduled.interval.ms=3600000 for hourly backups.

private static final int SEPARATOR_LENGTH = 80;

private final BackupService backupService;
private final List<String> scheduledUsers;
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public ScheduledBackupService(BackupService backupService,
@Value("${backup.scheduled.users:}") String scheduledUsersConfig) {
this.backupService = backupService;
// Parse comma-separated list of users/orgs
this.scheduledUsers = scheduledUsersConfig.isBlank()
? List.of()
: Arrays.stream(scheduledUsersConfig.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}

@PostConstruct
public void init() {
if (scheduledUsers.isEmpty()) {
System.out.println("⚠ Warning: No users/organizations configured for scheduled backups.");
System.out.println("Set the 'backup.scheduled.users' property with a comma-separated list.");
System.out.println("Example: backup.scheduled.users=octocat,github");
} else {
System.out.println("GitHub Backup Daemon Mode");
System.out.println("========================");
System.out.println("Scheduled backups enabled for: " + String.join(", ", scheduledUsers));
System.out.println("Backup interval: Every 24 hours");
System.out.println("First backup will run immediately, then every 24 hours.");
System.out.println();
}
}

/**
* Runs backup for all configured users/organizations.
* Executes on startup and then every 24 hours.
*/
@Scheduled(fixedRate = BACKUP_INTERVAL_MS, initialDelay = 0)
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using fixedRate means the next backup will start exactly 24 hours after the previous backup started, not after it completes. If a backup takes longer than 24 hours to complete (unlikely but possible with very large organizations), multiple backup jobs could run concurrently. Consider using fixedDelay instead, which waits 24 hours after the previous backup completes before starting the next one, or document this behavior if concurrent backups are acceptable.

Suggested change
@Scheduled(fixedRate = BACKUP_INTERVAL_MS, initialDelay = 0)
@Scheduled(fixedDelay = BACKUP_INTERVAL_MS, initialDelay = 0)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Changed to fixedDelay (via fixedDelayString) to ensure next backup starts only after the previous one completes, preventing concurrent backups for large organizations.

public void runScheduledBackup() {
if (scheduledUsers.isEmpty()) {
return;
}

String timestamp = LocalDateTime.now().format(dateTimeFormatter);
System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH));
System.out.println("Starting scheduled backup at " + timestamp);
System.out.println("=".repeat(SEPARATOR_LENGTH));

for (String userOrOrg : scheduledUsers) {
try {
backupService.backupUserRepositories(userOrOrg);
} catch (IOException e) {
System.err.println("Error backing up " + userOrOrg + ": " + e.getMessage());
}
}

timestamp = LocalDateTime.now().format(dateTimeFormatter);
System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH));
System.out.println("Scheduled backup completed at " + timestamp);
System.out.println("Next backup will run in 24 hours.");
System.out.println("=".repeat(SEPARATOR_LENGTH) + "\n");
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Console output is used directly with System.out.println and System.err.println instead of a proper logging framework. While this works for a scheduled service, consider using SLF4J logger for consistency with Spring Boot best practices. This would allow users to configure log levels and output destinations through standard Spring Boot logging configuration.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Replaced System.out/System.err with SLF4J logger, allowing users to configure log levels and output destinations through standard Spring Boot logging configuration.

}
}
7 changes: 7 additions & 0 deletions src/main/resources/application-daemon.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Daemon mode configuration
spring.main.web-application-type=none
backup.mode=daemon

# Comma-separated list of GitHub users/organizations to backup automatically
# Example: backup.scheduled.users=octocat,github,spring-projects
backup.scheduled.users=
104 changes: 104 additions & 0 deletions src/test/java/com/github/backup/ScheduledBackupServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.github.backup;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class ScheduledBackupServiceTest {

@Mock
private BackupService backupService;

private ScheduledBackupService scheduledBackupService;

@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}

@Test
void testScheduledBackupService_WithNoUsers() {
scheduledBackupService = new ScheduledBackupService(backupService, "");

// Should not throw exception with empty users list
assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());

// Should not call backup service when no users configured
verifyNoInteractions(backupService);
}

@Test
void testScheduledBackupService_WithSingleUser() throws IOException {
scheduledBackupService = new ScheduledBackupService(backupService, "octocat");

scheduledBackupService.runScheduledBackup();

// Should call backup service once for the configured user
verify(backupService, times(1)).backupUserRepositories("octocat");
}

@Test
void testScheduledBackupService_WithMultipleUsers() throws IOException {
scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects");

scheduledBackupService.runScheduledBackup();

// Should call backup service for each configured user
verify(backupService, times(1)).backupUserRepositories("octocat");
verify(backupService, times(1)).backupUserRepositories("github");
verify(backupService, times(1)).backupUserRepositories("spring-projects");
}

@Test
void testScheduledBackupService_WithWhitespace() throws IOException {
scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects ");

scheduledBackupService.runScheduledBackup();

// Should trim whitespace from user names
verify(backupService, times(1)).backupUserRepositories("octocat");
verify(backupService, times(1)).backupUserRepositories("github");
verify(backupService, times(1)).backupUserRepositories("spring-projects");
}

@Test
void testScheduledBackupService_HandlesException() throws IOException {
scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github");

doThrow(new IOException("Test exception")).when(backupService).backupUserRepositories("octocat");

// Should continue to next user even if one fails
assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());

verify(backupService, times(1)).backupUserRepositories("octocat");
verify(backupService, times(1)).backupUserRepositories("github");
}

@Test
void testScheduledBackupService_WithEmptyStrings() throws IOException {
scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github");

scheduledBackupService.runScheduledBackup();

// Should filter out empty strings and only call for valid users
verify(backupService, times(1)).backupUserRepositories("octocat");
verify(backupService, times(1)).backupUserRepositories("github");
verifyNoMoreInteractions(backupService);
}

@Test
void testScheduledBackupService_WithBlankString() {
scheduledBackupService = new ScheduledBackupService(backupService, " ");

assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());

// Should not call backup service with blank configuration
verifyNoInteractions(backupService);
}
}
Loading