Tcl Part I · Module 12 · Files, Directories, and Paths
Directories: mkdir, cd, pwd, delete
The last chapter of the module covers the directory operations, which are small in number and have a few asymmetries worth knowing: creating a tree is idempotent and creating one over an existing file is not, deleting a missing path is fine and deleting a non-empty directory is not. It then makes an argument the registry asks for explicitly — that a script which changes directory is harder to reason about than one that builds paths — and earns it by running the same job both ways until one of them breaks.
Foundation12 min readTclPart IFilesDirectoriesmkdir
Part I · Module 12 · Chapter 12.13 · Directories: mkdir, cd, pwd, delete
1. Creating a tree
file mkdir creates every level it needs:
file mkdir out/run1/logs
puts "created : [file isdirectory out/run1/logs]"
puts "and : [file isdirectory out/run1], [file isdirectory out]"created : 1
and : 1, 1One call, three directories. There is no separate "make parents" flag because that is simply what it does.
It is idempotent — calling it again on a directory that exists does nothing and raises nothing:
file mkdir out/run1/logs
file mkdir out/run1/logs
puts "still fine : [file isdirectory out/run1/logs]"still fine : 1So you never need if {![file exists $d]} { file mkdir $d }. Just call it.
2. pwd and cd
set start [pwd]
puts "start : [file tail $start]"
cd out/run1
puts "now : [file tail [pwd]]"
cd $start
puts "back : [file tail [pwd]]"start : work
now : run1
back : workpwd always returns an absolute path. cd with no argument goes to the home directory, which is rarely what a script wants — pass a path explicitly.
3. Deleting
file delete removes files, and it is forgiving about things that are not there:
set fp [open gone.txt w] ; puts $fp "x" ; close $fp
file delete gone.txt
puts "deleted : [file exists gone.txt]"
file delete never-existed.txt
puts "missing path : no error"deleted : 0
missing path : no errorThat silence is deliberate and useful — file delete $tmp as cleanup needs no guard.
It is not forgiving about a directory with anything in it:
file mkdir full
set fp [open full/x.txt w] ; puts $fp "x" ; close $fp
file delete fullerror deleting "full": directory not emptyputs "still there : [file isdirectory full]"still there : 1-force removes it and everything inside:
file delete -force full
puts "after -force : [file isdirectory full]"after -force : 04. Copying and renaming
Two more, for completeness:
set fp [open original.txt w] ; puts $fp "content" ; close $fp
file copy original.txt duplicate.txt
puts "copied : [file exists duplicate.txt]"
file rename duplicate.txt renamed.txt
puts "renamed : [file exists duplicate.txt] -> [file exists renamed.txt]"copied : 1
renamed : 0 -> 1Both refuse to overwrite an existing target unless given -force:
file copy original.txt renamed.txterror copying "original.txt" to "renamed.txt": file already existsfile rename -force is what makes the write-to-temporary-then-rename pattern from Chapter 12.4 §7 work.
5. Why cd makes a script harder to reason about
The registry asks for this argument, so here it is with a demonstration rather than an assertion.
A script that changes directory has to change back, and getting that right is harder than it looks:
proc process_with_cd {dir} {
cd $dir
set files [glob -nocomplain -types f *.csv]
if {[llength $files] == 0} {
return "nothing to do" ;# <- never cds back
}
cd ..
return "processed [llength $files] file(s)"
}
file mkdir workdir/withdata workdir/empty
set fp [open workdir/withdata/a.csv w] ; puts $fp "x" ; close $fp
set home [pwd]
puts "start : [file tail [pwd]]"
puts [process_with_cd workdir/withdata]
puts "after : [file tail [pwd]]"start : work
processed 1 file(s)
after : workdirThat is the path that works, and it already left us somewhere unintended. The procedure descended two levels — workdir then withdata — and climbed back only one, because cd .. is a guess about how deep we went. We are now in workdir.
The next call then fails, and the message is about something else entirely:
process_with_cd workdir/emptycouldn't change working directory to "workdir/empty": no such file or directoryworkdir/empty exists perfectly well — just not relative to workdir, which is where the previous call abandoned us. The error names the directory rather than the real problem, which is that the current directory is not what the caller assumed.
And the early-return path never restores anything at all, so a run that finds nothing to do leaves the script one level deeper still.
The version that builds paths has none of this:
cd $home
proc process_with_paths {dir} {
set files [glob -nocomplain -directory $dir -types f *.csv]
if {[llength $files] == 0} { return "nothing to do" }
return "processed [llength $files] file(s)"
}
puts [process_with_paths workdir/withdata]
puts [process_with_paths workdir/empty]
puts "pwd unchanged : [file tail [pwd]]"processed 1 file(s)
nothing to do
pwd unchanged : workTwo calls, both correct, in any order, from anywhere.
6. If you must cd, do it safely
proc in_directory {dir script} {
set saved [pwd]
cd $dir
set code [catch {uplevel 1 $script} result]
cd $saved
if {$code} { return -code error $result }
return $result
}
set home [pwd]
puts [in_directory workdir/withdata { llength [glob -nocomplain *.csv] }]
puts "pwd after : [file tail [pwd]]"
catch {in_directory workdir/withdata { error "something failed" }} m
puts "after error : [file tail [pwd]] ($m)"1
pwd after : work
after error : work (something failed)The cd back happens whether the body succeeded or failed — the same shape as the open/close wrapper from Chapter 12.1 §6, and it uses Chapter 11.5's uplevel so the body runs in the caller's scope.
7. A worked example
Setting up an output tree, writing into it, and cleaning up the previous run:
proc prepare_run {base name} {
set run [file join $base $name]
if {[file isdirectory $run]} {
file delete -force $run ;# a path we built, not one we were given
}
file mkdir [file join $run logs]
file mkdir [file join $run reports]
return $run
}
set run [prepare_run output run42]
puts "run dir : $run"
set fp [open [file join $run logs build.log] w]
puts $fp "started"
close $fp
set fp [open [file join $run reports summary.txt] w]
puts $fp "cycles 12000"
close $fp
foreach d [lsort [glob -nocomplain -tails -directory $run -types d *]] {
set files [lsort [glob -nocomplain -tails -directory [file join $run $d] -types f *]]
puts " $d/ -> [llength $files] file(s): [join $files {, }]"
}
puts "rerun gives a clean tree: [prepare_run output run42]"
puts " logs now : [llength [glob -nocomplain -directory [file join $run logs] *]] file(s)"run dir : output/run42
logs/ -> 1 file(s): build.log
reports/ -> 1 file(s): summary.txt
rerun gives a clean tree: output/run42
logs now : 0 file(s)Every path is built with file join, the script never changes directory, and the -force delete is applied to a path the procedure constructed from its own arguments.
8. What Module 12 covered
Thirteen chapters on talking to the filesystem:
- Channels — one idea covering files, the terminal and pipes, and the obligation to close what you open
- The canonical reading loop, and why its condition is
>= 0 - Whole-file reading, and the trailing empty element that catches everyone
- Writing, and the missing channel argument that silently prints to your terminal instead
wversusa, and thatwempties the file atopenrather than at the first write- Buffering — a file at zero bytes while your script has correctly written to it
eof, which is retrospective, and the loop built on it that always runs once too often- Missing files, and why checking before opening is a race that
catchdoes not have - The
filecommand, split into predicates that are safe and accessors that raise - Path decomposition, and why no string-command recipe is correct
file normalize, for paths that survive a change of directoryglob, which raises rather than returning an empty list- Directories, and why building paths beats changing into them
A thread runs through almost all of it: the filesystem is outside your script, and it answers questions about a moment that has already passed. file exists was true a moment ago. The buffer was flushed a moment ago. The file was there when you checked. Every surprise in this module comes from the gap between asking and acting — which is why catch appears in so many of these chapters, and why the honest shape is usually "try it and handle failure" rather than "check and assume".
9. What comes next
Module 13 is Regular Expressions, and its placement is deliberate. You now have string match, string first, string range, split, scan and the file path commands — which between them handle most of what people reach for a regular expression to do, and handle it more readably. Module 13 can therefore teach regexes as the tool for the problems that genuinely need one, rather than as the first hammer.
After that, Part I closes with Module 14, Errors and Debugging — which will formalise the catch you have been using throughout this module — and Module 15, Building Tcl Programs.
10. Exercises
Exercise 1. Does file mkdir a/b/c need the parent directories to exist? What if the whole path already exists?
Exercise 2. What happens if you file mkdir a path that is already an ordinary file?
Exercise 3. What does file delete do with a path that does not exist, and with a non-empty directory?
Exercise 4. Give three rules for using file delete -force safely.
Exercise 5. Explain what goes wrong in process_with_cd when the directory is empty.
Exercise 6. Write in_directory that runs a script in a given directory and always returns to where it started.
Answers
1. No — it creates every missing level. And calling it on a path that already exists does nothing and raises nothing, so no existence check is needed.
2. It fails with can't create directory "name": file already exists. A path that is already a file cannot become a directory.
3. A missing path is silently ignored, which makes it safe as cleanup. A non-empty directory raises error deleting "name": directory not empty and is left alone — -force is required to remove a tree.
4. Never pass it a path that came from outside the script without validating it; never pass a variable that might be empty; and delete paths you constructed rather than paths you were handed.
5. Two things. The early return skips the cd .. entirely, leaving the script in the subdirectory. And even the successful path is wrong: it descended two levels and cd .. climbs only one, so it ends up in workdir rather than where it started. Both leave later relative paths resolving against the wrong directory, and the eventual error names a directory rather than the real cause.
6.
proc in_directory {dir script} {
set saved [pwd]
cd $dir
set code [catch {uplevel 1 $script} result]
cd $saved
if {$code} { return -code error $result }
return $result
}11. Remember
file mkdircreates every missing level and is idempotent — no existence check needed.- It fails over an existing ordinary file:
can't create directory "x": file already exists. file deleteignores a missing path (safe as cleanup) but refuses a non-empty directory.file delete -forceremoves a whole tree with no undo — validate anything that came from outside, never pass an empty variable, and delete paths you built.file copyandfile renamerefuse to overwrite unless given-force.pwdis absolute;cdwith no argument goes home, which a script rarely wants.- A script that
cds has tocdback from every exit path, and forgetting one silently changes the meaning of every later relative path. - Prefer
-directoryandfile join— the directory becomes a value you pass rather than hidden global state. - If you must
cd, wrap it so the restore happens even on failure.
Where this fits
Part of the Tcl curriculum.
