Skip to content

Instantly share code, notes, and snippets.

@fsubal
Last active January 12, 2026 03:14
Show Gist options
  • Select an option

  • Save fsubal/4d663c9fad65724c57993d2da1873605 to your computer and use it in GitHub Desktop.

Select an option

Save fsubal/4d663c9fad65724c57993d2da1873605 to your computer and use it in GitHub Desktop.
package com.example.gitstatus;
public class FileDiff {
/**
* @see https://git-scm.com/docs/git-status#_porcelain_format_version_1
*/
private static final Pattern PORCELAIN_FORMAT_VERSION_1 = Pattern.compile("^( M| D|\?\?)\s*(.+)$");
/**
* 本当はこれ以外にもあるが、これしか想定しない
*
* @see https://git-scm.com/docs/git-status#_porcelain_format_version_1
*/
public enum DiffType {
MODIFIED,
DELETED,
ADDED
public static DiffType fromString(String diffType) {
return switch (diffType) {
case " M" -> MODIFIED;
case " D" -> DELETED;
case "??" -> ADDED;
default -> throw new IllegalArgumentException("Invalid diffType: " + diffType);
};
}
}
public static class InvalidLineException extends Exception {
public InvalidLineException(String message, Throwable cause) {
super(message, cause);
}
}
public static FileDiff of(String line, Directory directory) throws InvalidLineException {
try {
var matcher = PORCELAIN_FORMAT_VERSION_1.matcher(line);
if (!matcher.matches()) {
throw new InvalidLineException("Invalid line: " + line, null);
}
var filePath = directory.getPath().resolve(Paths.get(matcher.group(2)).normalize());
var status = Status.fromString(matcher.group(1));
return new FileDiff(filePath, status);
} catch (IOException e) {
throw new InvalidLineException("Failed to resolve path: " + matcher.group(2), e);
}
}
private final Path filePath;
private final DiffType diffType;
private FileDiff(Path filePath, DiffType diffType) {
this.filePath = filePath;
this.diffType = diffType;
}
public boolean isModified() {
return diffType == DiffType.MODIFIED;
}
public boolean isDeleted() {
return diffType == DiffType.DELETED;
}
public boolean isAdded() {
return diffType == DiffType.ADDED;
}
public DiffType getDiffType() {
return diffType;
}
public Path getFilePath() {
return filePath;
}
public byte[] getFileContent() throws InvalidFileException {
try {
return Files.readAllBytes(filePath);
} catch (IOException e) {
throw new InvalidFileException("Failed to read file: " + filePath, e);
}
}
}
public class GitStatus {
private static final REPOSITORY_ROOT = new Directory("/path/to/repo");
private final Path directory;
private GitStatus(Directory directory) {
this.directory = directory;
}
public static List<FileDiff> executeIn(Directory directory) {
return new GitStatus(directory).execute();
}
public static List<FileDiff> executeInRepositoryRoot() {
return new GitStatus(REPOSITORY_ROOT).execute();
}
public List<FileDiff> execute() {
try {
var process = Runtime.getRuntime().exec("git status --porcelain");
var reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
return reader.lines()
.map(line -> FileDiff.of(line, directory))
.toList();
} catch (IOException e) {
throw new RuntimeException("Failed to execute git status: " + e.getMessage(), e);
}
}
}
public class Main {
public static void main(String[] args) {
var gitStatus = GitStatus.executeInRepositoryRoot();
gitStatus.forEach(fileDiff -> {
System.out.println(fileDiff.getFilePath());
System.out.println(fileDiff.getDiffType());
});
}
}
import { resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
const execp = promisify(exec)
/**
* @see https://git-scm.com/docs/git-status#_porcelain_format_version_1
*/
const PORCELAIN_FORMAT_VERSION_1 = /( M| D|\?\?)\s*(.+)/
/**
* 本当はこれ以外にもあるが、これしか想定しない
*
* @see https://git-scm.com/docs/git-status#_porcelain_format_version_1
*/
export const enum ChangedFileStatus {
Modified = ' M',
Deleted = ' D',
Added = '??'
}
export interface ChangedFile {
fullpath: string
status: ChangedFileStatus
content: string
}
type GitStatusEntry = [relativePath: string, status: ChangedFileStatus]
export class GitStatus {
#entries: ReadonlyMap<GitStatusEntry[0], GitStatusEntry[1]>
#dirname: string
constructor(entries: Iterable<GitStatusEntry>, dirname = process.cwd()) {
this.#entries = new Map(entries)
this.#dirname = dirname
}
static async porcelain(dirname = process.cwd()) {
const lines = await execp('git status --porcelain').then(({ stdout }) => stdout.trim().split('\n'))
const entries = lines.map<GitStatusEntry>(line => {
const [, status, relativePath] = line.match(PORCELAIN_FORMAT_VERSION_1) ?? []
switch(status) {
case ChangedFileStatus.Added:
case ChangedFileStatus.Deleted:
case ChangedFileStatus.Modified: {
return [relativePath, status]
}
default: {
throw new RangeError(`Unsupported git status: ${line}`)
}
}
})
return new this(entries, dirname)
}
async *[Symbol.asyncIterator]() {
for (const [relativePath, status] of this.#entries) {
const fullpath = resolve(this.#dirname, relativePath)
const content = await readFile(fullpath, { encoding: 'utf8' })
yield { fullpath, status, content } as ChangedFile
}
}
}
const gitStatus = await GitStatus.porcelain()
for await (const changedFile of gitStatus) {
console.log(changedFile.status, changedFile.fullpath, changedFile.content)
}
new GitStatus([
['app/models/item.rb', ChangedFileStatus.Modified],
['spec/models/item_spec.rb', ChangedFileStatus.Added]
])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment