共有フォルダ教が支配する世界に転生した場合の対応案

Table of Content

前書き

この世界ではバージョン管理ソフトが世の中に出回り始めて20年以上、フリーでかつ信頼性も高くなったツールが流行りだして10年以上はたっています。

さすがにソースコードはバージョン管理をするところが多くなりましたが、ドキュメントや、その他重要なデータはいまだバージョン管理をしない世界をよく見かけます。

バージョン管理ツールを縛り共有フォルダでの管理を支持する、いや、もはや信仰している方々は、提案されるバージョン管理ツールの導入案を却下あるいは先延ばしにし続けてきました。
その結果として作業者がフォルダを誤操作で消したり、古いファイルを上書きしたりするという必然の結果(彼らにとっては想定外な結果)に遭遇し、時間とリソースを無駄にドブに捨てるという贅沢をし続けています。

今回は、不幸にも、そのような世界に転生してしまった場合に、どうすべきかを考えてみます。

なお、この記事はバージョン管理ソフトの使い方を広めて、みんなで協力して導入していく、模範的な話ではなく、ベストではないが、最悪が回避できたらラッキー程度の話になります。

バージョン管理ツールが流行り、10年以上たって導入できないのは導入できないなりの事情が存在するからです。

なぜバージョン管理ツールは導入されないか

なぜバージョン管理ツールが導入されないかは幾つかのパターンがあります。

  • バージョン管理ツールを使用する必要がないあるいは、使用できない
  • バージョン管理ツールを知らない
  • バージョン管理ツールは知っていても、使える人間が少ない
  • 『高度なIT管理』を実現している組織の場合
  • コミット履歴を残したくないケース

バージョン管理ツールを使用する必要がないあるいは、使用できない

状況としては2つ考えられます。
1つは管理対象の資源の重要性が低い場合です。
たとえば、一時的なファイルや、消えても大きな影響がない資産の場合です。

もう一つはバージョン管理をするのが難しい資産です。
たとえば、動画ファイルとか仮想マシンのイメージとかはファイルのサイズが大きすぎる上、簡単に差分を確認する方法がないため、バージョン管理の対象にむいてません。

こういった場合は、バージョン管理ツールを入れる必要はありません。

バージョン管理ツールを知らない

バージョン管理ツール自体を知らない人は少なくなってきてますが、バージョン管理でなにができるかを理解している人はいまだ少数です。

たとえば、昔からの人間の場合、バージョン管理ツールはソースコードや設定ファイルといったテキストしか管理できないと思っている人がいます。このため、ExcelやWordといったOffice文章等は差分の確認もできないし、バージョン管理をする意味がないと思い込んでいます。

これは、ひと昔前は事実でしたが、今現在は、ただの思い込みです。
TotoiseHGやTotoiseSVNなどに付属するDiffツールは標準でOffice文章の差分をとることができますし、WinMergeなどのDiffツールは様々なプラグインがサポートされており画像だろうが差分を見ることが可能になっています。

「知らないで導入しない」パターンは、他のパターンに比べて模範的な方向にもっていく可能性が微粒子レベルで存在します。

バージョン管理ツールは知っていても、使える人間が少ない

管理者レベルでバージョン管理ツールのメリットを知っていても、末端の作業者がバージョン管理ツールを使えないなら、結局は導入はできません。

バージョン管理を使えない人間が少数、または教育期間が十二分に取れる場合、練習用のリポジトリを用意してバージョン管理ツールの教育を行うのは有効です。このリポジトリはプロダクトコードと完全に切り離して、最悪、破壊させていい状況にしておいたほうが教育効果が高いです。

…とはいえ、バージョン管理の使い方を学んでこなかった人間を大量に雇用せざる得ない状況は、たいてい学徒動員的な末期戦になっていることが多いので、教育とかいう贅沢は許されない状況である場合が多いでしょう。

『高度なIT管理』を実現している組織の場合

いわゆる『高度なIT管理』を実現している組織の場合、あまりに『高度』すぎてインターネットの接続を遮断したり、サーバー管理を『完全で完璧な高度な専門集団』にしかやらせないという方針の場合があります。

この場合、サーバーを自由に立ててバージョン管理を行うということはできません。

そして『完全で完璧な高度な専門集団』を説得する、あるいは動いていただくために掛ける労力は、しばしば、数度の共有フォルダの破壊のリスクを許容するに値する労力になる場合があります。

コミット履歴を残したくないケース

過剰な管理ごっこをしている世界においては、その防衛反応として事実を隠す反応が発生します。

そういった防衛反応として、表ざたにできないバグをこっそり直したり、試験の不具合件数をいじったりするダークサイドに堕ちた中間管理職が生まれたりします。彼らにとって闇の技法を暴いてしまうバージョン管理システムというのは相性が悪いでしょう。

共有フォルダの破壊にそなえる

すべてを捨てて旅にでるのも一つの選択肢ですが、無駄な足掻きはいくつか考えました。
外部からの支援がほぼない状況で、共有フォルダの致命的な破壊に備える程度の案になります。

案1:自分でバックアップを取る

もっとも単純な案は1日に1回、夜中にでも共有フォルダの内容を自分のPCにコピーしておくことです。
この際、フォルダ名にタイムスタンプでもつけておいて、履歴としてつかえばよいでしょう。
Copyと同時に履歴を削除しておけば、バックアップフォルダの増加も抑えられると思います。

PowerShellで実現する場合は以下のようになります。

$server = '\\IEWIN7\share\test'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\bk'

# 残す履歴の数
$backup_cnt = 10

if ((Test-Path $server) -eq $True) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred -ea Stop
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

$time = Get-Date -Format "yyyyMMddHHmmss"
$backup_path = Join-Path $backup_root $time
Write-Host "BACKUP.... $backup_path "
Copy-Item -Literal $server -Destination $backup_path -Recurse

# 古いフォルダの削除
Get-ChildItem -Literal $backup_root | 
    Sort-Object LastWriteTime -Descending  | 
    Select-Object -Skip $backup_cnt | 
    foreach { 
        Write-Host "Delete..." $_.FullName
        $rmpath = $_.FullName
        $cmd = "rmdir /s/q `"$rmpath`""
        cmd /c $cmd
    }

この案の素晴らしいところは、『高度なIT管理』下でも、やれるということです。
問題点としては、すべてを単純にバックアップするため履歴を追うのは困難ですし、ディスク容量を無駄に消費します。

案2:分散型バージョン管理システムを共有フォルダに対して使用する

image.png

①と②で共有フォルダ中のファイルを分散バージョン管理システムの管理下に入れます。
③と④で共有フォルダのリポジトリをローカルにクローンすることでバックアップとします。

定期的に②のadd&commitと④のpullを実行することに共有フォルダで変更された内容が履歴管理され、その内容はバックアップフォルダに反映されます。
定期的に実行されるaddとcommitは共有フォルダに変化がなければ履歴が作成されません。

これにより更新されたファイル内容だけが履歴管理されるようになります。バージョン管理ツールがサポートする変更履歴の確認や、特定バージョンの差し戻しなどが容易に利用できるようになります。

この構成のデメリットは以下の通りです。

  • 誰が更新したかわからない
  • 共有フォルダ中に管理ファイルが置かれるためサイズが増える。
    • これは歴がすすむにつれサイズが増大していく。
      • どこかのタイミングで履歴の削除が必要になる
      • 共有サーバーのディスクサイズが制限されている環境では使えない

Gitでの実現例

Gitで上記の構成を実現するためのPowerShellのスクリプト例を以下に記載します。
なおバージョンは「git version 2.23.0.windows.1」となります。

$server = '\\IEWIN7\share\testgit'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\gitbk'

if ((Test-Path $server) -eq $False) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

Push-Location $server

if ((Test-Path '.git') -eq $False) {
    # Gitフォルダではない
    Write-Host "Init.........."
    git init
}
git add --all
git commit -a -m "auto commit"
git gc
Pop-Location

# ローカルにコピー
Write-Host "BACKUP... $backup_root"
mkdir $backup_root -Force
Push-Location $backup_root
if ((Test-Path '.git') -eq $False) {
    # Gitフォルダではない
    $clone_remote = $server -replace '\\', '/'
    git clone $clone_remote .
}
git pull origin master

Pop-Location

容量の節約と効率化のため、Gitはときどき、緩いフォーマットのオブジェクトの中の幾つかを1つのバイナリファイルにパックします。緩いフォーマットのオブジェクトの場合、ちょっとだけ変更したファイルであってもそれぞれオブジェクトとして存在します。
しかし、パックをすることによりそれらをまとめて変更点だけを保持するようにします。

この処理は「git gc」コマンドを実行することで手動でも実現できます。
https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-Packfile

この処理を行わないと共有サーバーのファイルサイズが更新のたびに想定以上に増大していくことになります。

共有フォルダでGit管理外の名前の変更が発生した場合、AddとCommit時に変更前のオブジェクトと履歴を紐づけることができるようです。

Mercurialの実装例

Gitで上記の構成を実現するためのPowerShellのスクリプト例を以下に記載します。
なおバージョンは「Mercurial - 分散構成管理ツール(バージョン 4.9.1)」となります。

$server = '\\IEWIN7\share\testhg'
$user = 'IEWIN7\IEUser'
$password = ConvertTo-SecureString 'Passw0rd!' -AsPlainText -Force
$backup_root = 'C:\dev\ps\share\hgbk'

if ((Test-Path $server) -eq $False) {
    Write-Host '接続....'
    $cred = New-Object System.Management.Automation.PSCredential($user, $password)
    $drive = New-PSDrive -Name "Z" -PSProvider FileSystem -Root $server -Credential $cred
    if ((Test-Path $server) -eq $False) {
        Write-Error "$server に接続できません"
        exit 1
    }
}

Push-Location $server

if ((Test-Path '.hg') -eq $False) {
    # HGフォルダではない
    Write-Host "Init.........."
    hg init
}
hg add
hg commit -A -m "auto commit"

Pop-Location

# ローカルにコピー
Write-Host "BACKUP... $backup_root"
mkdir $backup_root -Force
Push-Location $backup_root
if ((Test-Path '.hg') -eq $False) {
    # HGフォルダではない
    $clone_remote = $server -replace '\\', '/'
    hg clone $clone_remote .
}
hg pull default
hg update

Pop-Location

Gitと違い、MercurialはGCの必要がありません。
共有フォルダでMercurial管理外の名前の変更が発生した場合、Gitとことなり変更前のオブジェクトと履歴を紐づけはできないようです。

容量増加の検証

どちらのバージョン管理ツールを使用しても共有フォルダ内に履歴の管理情報を保持する必要があります。
ここで発生する懸念としては、共有フォルダのサイズがどの程度増量するかです。

今回は以下の条件で検証しました。

(1)下記のスクリプトを用いて任意のフォルダに1000行x251列のエクセルファイル(1MB前後)を100個作成する。


    Param(
        [String]$target_dir,
        [Int]$count,
        [Int]$rowcnt,
        [Int]$colcnt
    )

    function create_xls($app, [String]$path, [String[]]$ary, $rowcnt) {
        Write-Host $path
        $books = $app.Workbooks
        $book = $books.Add()
        $sheets = $book.Sheets
        $sheet = $sheets["Sheet1"]
        $cells = $sheet.Cells

        $fromCell = $cells[1,1]
        $toCell = $cells[$rowcnt,$ary.Length]
        $rng = $sheet.Range($fromCell, $toCell)
        $rng.Value = $ary
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($fromCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($toCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($rng) | Out-Null

        $book.SaveAs($path)
        $book.Close()

        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null

        $cell = $null
        Remove-Variable cell -ErrorAction SilentlyContinue
        $cells = $null
        Remove-Variable cells -ErrorAction SilentlyContinue
        $sheet = $null
        Remove-Variable sheet -ErrorAction SilentlyContinue
        $sheets = $null
        Remove-Variable sheets -ErrorAction SilentlyContinue
        $book = $null
        Remove-Variable book -ErrorAction SilentlyContinue
        $books = $null
        Remove-Variable books -ErrorAction SilentlyContinue

        [System.GC]::Collect()
        [System.GC]::WaitForPendingFinalizers()
        [System.GC]::Collect()

    }

    $app = New-Object -ComObject Excel.Application
    $app.DisplayAlerts = $False
    for ($i = 0; $i -lt $count; $i++) {
        $name = "test" + $i + ".xlsx"
        $path = Join-Path $target_dir $name
        $t = Get-Date -Format "yyyyMMddHHmmss"
        $ary = @($t)
        for ($j = 0; $j -lt $colcnt; $j++) {
            $ary += Get-Random 10000
        }
        create_xls $app $path $ary $rowcnt
    }
    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

(2)下記のスクリプトを用いて1列目の全行を全て変更して保存します。

    Param(
        [String]$target_dir,
        [Int]$count,
        [Int]$rowcnt
    )

    function update_xls($app, [String]$path, [String]$time, $rowcnt) {
        Write-Host $path
        $books = $app.Workbooks
        $book = $books.Open($path)
        $sheets = $book.Sheets
        $sheet = $sheets["Sheet1"]
        $cells = $sheet.Cells

        $fromCell = $cells[1,1]
        $toCell = $cells[$rowcnt,1]
        $rng = $sheet.Range($fromCell, $toCell)
        $rng.Value = $time
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($fromCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($toCell) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($rng) | Out-Null

        $book.SaveAs($path)
        $book.Close()

        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
        [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null

        $cell = $null
        Remove-Variable cell -ErrorAction SilentlyContinue
        $cells = $null
        Remove-Variable cells -ErrorAction SilentlyContinue
        $sheet = $null
        Remove-Variable sheet -ErrorAction SilentlyContinue
        $sheets = $null
        Remove-Variable sheets -ErrorAction SilentlyContinue
        $book = $null
        Remove-Variable book -ErrorAction SilentlyContinue
        $books = $null
        Remove-Variable books -ErrorAction SilentlyContinue

        [System.GC]::Collect()
        [System.GC]::WaitForPendingFinalizers()
        [System.GC]::Collect()

    }

    $app = New-Object -ComObject Excel.Application
    $app.DisplayAlerts = $False
    for ($i = 0; $i -lt $count; $i++) {
        $name = "test" + $i + ".xlsx"
        $path = Join-Path $target_dir $name
        $time = Get-Date -Format "yyyyMMddHHmmss"
        update_xls $app $path $time $rowcnt
    }
    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

(3)(2)の実行後、コミットし、コミット後のフォルダのサイズを調べます。これを10回繰り返します

Gitの場合

$src_dir = "C:\dev\ps\share\kensyou\test_dir"
$target_dir = "C:\dev\ps\share\kensyou\git"
$script_dir = Split-Path $MyInvocation.MyCommand.Path

function gettime() {
    return Get-Date -Format "yyyyMMddHHmmss"
}

mkdir $target_dir -force | Out-Null
Copy-Item $($src_dir+"\*") $target_dir

push-location $target_dir
$time = gettime
Write-Host "$time  構成管理前" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

git init | Out-Null
$time = gettime
Write-Host "$time  init後" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

for ($i = 1; $i -le 10; $i++) {
    if ($i -ne 1) {
        $script = Join-Path $script_dir "update_data.ps1"
        powershell -file $script  $target_dir 100 1000 250  | Out-Null
    }
    git add --all | Out-Null
    git commit -a -m "auto commit" | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目(commit後)"    (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
    git gc | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目(gc後)"    (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
}
pop-location

Mercurialの場合

$src_dir = "C:\dev\ps\share\kensyou\test_dir"
$target_dir = "C:\dev\ps\share\kensyou\hg"
$script_dir = Split-Path $MyInvocation.MyCommand.Path

function gettime() {
    return Get-Date -Format "yyyyMMddHHmmss"
}

mkdir $target_dir -force | Out-Null
Copy-Item $($src_dir+"\*") $target_dir

push-location $target_dir
$time = gettime
Write-Host "$time  構成管理前" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

hg init | Out-Null
$time = gettime
Write-Host "$time  init後" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum

for ($i = 1; $i -le 10; $i++) {
    if ($i -ne 1) {
        $script = Join-Path $script_dir "update_data.ps1"
        powershell -file $script  $target_dir 100 1000 250  | Out-Null
    }
    hg add | Out-Null
    hg commit -A -m "auto commit" | Out-Null

    $time = gettime
    Write-Host "$time  $i 回目" (Get-ChildItem -LiteralPath $target_dir -Recurse -Force | Measure-Object -Sum Length).Sum
}
pop-location

計測結果

Gitの結果

時刻 経過時間 説明 バイト数 変化バイト数
12:40:42 構成管理前 108,224,999
12:40:42 0:00:00 init後 108,240,786 15,787
12:40:46 0:00:04 1回目(commit後) 167,167,147 58,926,361
12:41:03 0:00:17 1回目(gc後) 160,253,884 -6,913,263
12:42:38 0:01:35 2回目(commit後) 223,790,889 63,537,005
12:43:00 0:00:22 2回目(gc後) 217,598,218 -6,192,671
12:44:38 0:01:38 3回目(commit後) 279,699,191 62,100,973
12:45:13 0:00:35 3回目(gc後) 272,927,795 -6,771,396
12:46:55 0:01:42 4回目(commit後) 335,808,640 62,880,845
12:47:22 0:00:27 4回目(gc後) 329,101,286 -6,707,354
12:49:06 0:01:44 5回目(commit後) 391,427,905 62,326,619
12:49:44 0:00:38 5回目(gc後) 384,702,914 -6,724,991
12:51:50 0:02:06 6回目(commit後) 447,154,138 62,451,224
12:52:57 0:01:07 6回目(gc後) 440,272,643 -6,881,495
12:54:51 0:01:54 7回目(commit後) 502,722,692 62,450,049
12:55:25 0:00:34 7回目(gc後) 495,815,728 -6,906,964
12:57:07 0:01:42 8回目(commit後) 558,031,925 62,216,197
12:57:42 0:00:35 8回目(gc後) 551,140,571 -6,891,354
12:59:24 0:01:42 9回目(commit後) 613,900,051 62,759,480
13:00:01 0:00:37 9回目(gc後) 607,291,311 -6,608,740
13:01:35 0:01:34 10回目(commit後) 669,533,780 62,242,469
13:02:13 0:00:38 10回目(gc後) 662,895,263 -6,638,517

Mercurialの結果

時刻 経過時間 説明 バイト数 変化バイト数
13:06:28 構成管理前 108,224,999
13:06:30 0:00:02 init後 108,225,115 116
13:06:40 0:00:10 1回目 160,990,957 52,765,842
13:08:27 0:01:47 2回目 217,741,339 56,750,382
13:10:11 0:01:44 3回目 274,194,265 56,452,926
13:12:06 0:01:55 4回目 330,463,591 56,269,326
13:14:03 0:01:57 5回目 386,622,952 56,159,361
13:16:20 0:02:17 6回目 442,364,652 55,741,700
13:18:28 0:02:08 7回目 498,348,170 55,983,518
13:20:14 0:01:46 8回目 554,076,263 55,728,093
13:22:02 0:01:48 9回目 609,921,087 55,844,824
13:24:05 0:02:03 10回目 666,193,613 56,272,526

ファイルのサイズとしては「GC後のGitのサイズ<Mercurialサイズ<GC前のGitのサイズ」となります。
10回程度のコミットで6倍のサイズになっています。

ファイルの更新~コミット&GCの時間はGitとMercurialに大きな差はないように見えます。
※時間の注意としては、100ファイルに対して列を書き換える時間も含まれていることに注意してください。

どう実行すべきか

add & commitのタイミングはとりあえず1日単位でおこなって共有フォルダのサイズの増加量を監視して調整したほうがよさそうです。

また、更新頻度にもよりますが、バージョン管理の情報が肥大化することが予想されます。
そのため、どのタイミングで履歴を捨てるかを検討した方がいいです。
(たとえばリリースが行われた時点で開発中の履歴を捨てる等)

案3 ローカルにコピーしたバックアップフォルダに対して分散バージョン管理を使用する

案1のやりかたでローカルに共有フォルダの内容を定期的にコピーします。
ただし、フォルダ毎にわけるのでなく、上書き保存として、保存後、コミットをします。

このメリットは共有フォルダのサイズの肥大化を防げることです。
デメリットとしては、その履歴の活用が個人でしか行えないことです。

参考

Powershellで共有フォルダにアクセス
https://qiita.com/mugippoi/items/608255dee3fc9d3b35dc

Gitの内側
https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-%E9%85%8D%E7%AE%A1%EF%BC%88Plumbing%EF%BC%89%E3%81%A8%E7%A3%81%E5%99%A8%EF%BC%88Porcelain%EF%BC%89

まとめ

今回は共有フォルダ教が支配する世界に転生した場合の対応案を考えてみました。

おそらくは最悪の状況を回避するという手としては使えないこともないでしょうが、転生しなおした方が楽だと思います。

(補足)
なお、まじめにまっとうなバージョン管理の導入を考えるなら以下で紹介している「パターンによるソフトウェア構成管理」を基に現場にあった論を組み立てた方がいいと思います。

古典を読む~パターンによるソフトウェア構成管理
https://needtec.sakura.ne.jp/wod07672/?p=9172

「共有フォルダ教が支配する世界に転生した場合の対応案」への1件の返信

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です