Shell 脚本如何定位项目根目录——从一个 8 行的小脚本讲起

文章目录

    • [0. 从一个实际问题出发](#0. 从一个实际问题出发)
    • [1. 预备知识:五个零件](#1. 预备知识:五个零件)
      • [1.1 `()\` ------ 命令替换](#1.1 `()` —— 命令替换)
      • [1.2 `{BASH_SOURCE\[0\]}\` ------ 脚本自己的路径](#1.2 `{BASH_SOURCE[0]}` —— 脚本自己的路径)
      • [1.3 `dirname` ------ 取目录部分](#1.3 dirname —— 取目录部分)
      • [1.4 `cd 目录 && pwd` ------ 相对路径转绝对路径的经典技巧](#1.4 cd 目录 && pwd —— 相对路径转绝对路径的经典技巧)
      • [1.5 `..` ------ 上一级目录](#1.5 .. —— 上一级目录)
    • [2. 正式拆解三行代码](#2. 正式拆解三行代码)
      • [第 1 行:算出脚本自己住在哪](#第 1 行:算出脚本自己住在哪)
      • [第 2 行:从脚本目录往上跳两级,得到项目根](#第 2 行:从脚本目录往上跳两级,得到项目根)
      • [第 3 行:真正切换过去](#第 3 行:真正切换过去)
    • [3. 完整流程演示](#3. 完整流程演示)
    • [4. 延伸话题](#4. 延伸话题)
      • [4.1 `set -euo pipefail` 是什么](#4.1 set -euo pipefail 是什么)
      • [4.2 为什么变量要加双引号](#4.2 为什么变量要加双引号)
      • [4.3 替代方案对比](#4.3 替代方案对比)
      • [4.4 已知局限](#4.4 已知局限)
    • [5. 总结](#5. 总结)

0. 从一个实际问题出发

本项目里有这样一个 Shell 脚本 read.sh,它调用 Python 脚本去读取一个

Markdown 文件:

bash 复制代码
python src/read_file.py dataset/file.md

注意这里用的都是相对路径 :src/read_file.pydataset/file.md

相对路径有一个非常容易踩坑的特性:它不是相对脚本文件本身,而是相对
"你运行命令时所在的目录"来解析的
。于是同一个脚本,在不同位置运行,

命运完全不同:

你在哪里运行 read.sh dataset/file.md 实际被解析为 结果
项目根目录 项目根/dataset/file.md ✅ 找得到
家目录 ~ ~/dataset/file.md ❌ 不存在
/tmp /tmp/dataset/file.md ❌ 不存在
script/linux/ 目录内 script/linux/dataset/file.md ❌ 不存在

只要用户不是"恰好"在项目根目录运行,脚本就会报错。解决方案思路很直接:

脚本启动时,先想办法 cd 到一个绝对可靠的位置(项目根目录),再执行
后续命令
。问题转化为------脚本怎么知道项目根目录在哪?

这就是本文的主角,read.sh 的前三行:

bash 复制代码
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"

下面逐层拆解。

1. 预备知识:五个零件

这三行代码只用到五个 Shell 基础构件,先把它们单独认清。

1.1 $() ------ 命令替换

bash 复制代码
VAR="$(命令)"

执行括号里的命令,把标准输出捕获为字符串,赋给变量。例如:

bash 复制代码
NOW="$(date '+%H:%M:%S')"   # NOW 的值形如 14:23:05

$() 支持嵌套,内层先执行。本文的三行代码都用了嵌套:

$(cd "$(dirname ...)" && pwd) 里,dirname ... 先算出来,再交给外层

cd 使用。

1.2 ${BASH_SOURCE[0]} ------ 脚本自己的路径

它是 Bash 的特殊数组变量,值为当前正在执行的脚本文件被调用时写的
路径
,就是你敲在命令行上的那串字符:

bash 复制代码
# 在项目根目录运行
$ bash script/linux/read.sh
# 脚本内 ${BASH_SOURCE[0]} 的值 = "script/linux/read.sh"

# 在 /tmp 用绝对路径运行
$ bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
# 脚本内 ${BASH_SOURCE[0]} 的值 = "/home/jie/.../script/linux/read.sh"

注意两个特点:

  • 它可能是相对路径 (如 script/linux/read.sh),取决于用户怎么调用;
  • 它指向脚本文件本身,这正是我们需要的锚点。

为什么不用更常见的 $0?$0 是"当前 Shell 进程的名字",在脚本被

source(点号)加载、或作为函数库被复用时,$0 会指向外层调用者,

BASH_SOURCE[0] 始终指向当前这份脚本文件,更可靠 。写成

${BASH_SOURCE[0]} 而不是 $BASH_SOURCE 是显式取数组第 0 个元素,

并明确了变量名的边界。

1.3 dirname ------ 取目录部分

一个外部命令,去掉路径的最后一层,保留目录部分:

bash 复制代码
dirname /home/jie/project/script/linux/read.sh   # -> /home/jie/project/script/linux
dirname script/linux/read.sh                     # -> script/linux
dirname ./read.sh                                # -> .

1.4 cd 目录 && pwd ------ 相对路径转绝对路径的经典技巧

pwd(print working directory)打印当前目录的绝对路径。于是:

bash 复制代码
cd script/linux && pwd
# -> /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux

先进到那个目录,再让 pwd 报出它的"全名"。无论输入是 ./x

x/../y 还是符号链接的各种写法,cd + pwd 都会给出一条规范化的

绝对路径。这是 Shell 里把任意路径"洗"成绝对路径的最常用手段。

一个重要细节:$( cd ... && pwd ) 里的 cd 发生在子 Shell 中,

只影响 $() 内部,不会改变你终端或脚本当前所在的目录 ------纯粹是

借它来问路,不改变位置。

1.5 .. ------ 上一级目录

路径中每个 .. 表示向上跳一级:

复制代码
项目根/script/linux/../..       等价于  项目根/
项目根/script/linux/../read.sh  等价于  项目根/script/read.sh

2. 正式拆解三行代码

零件备齐,现在把三行代码合起来看。

第 1 行:算出脚本自己住在哪

bash 复制代码
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

执行顺序(由内向外):

复制代码
${BASH_SOURCE[0]}          "script/linux/read.sh"        (用户怎么调用就长什么样)
        |
        v
dirname ...                "script/linux"                (去掉文件名)
        |
        v
cd ... && pwd              "/home/jie/github/ouc_projects/sar_isar_mosaic/script/linux"
        |                                                       (洗成绝对路径)
        v
SCRIPT_DIR                 /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux

三种典型的调用方式,得到的 SCRIPT_DIR 完全一致:

调用方式 BASH_SOURCE[0] 最终 SCRIPT_DIR
项目根目录下 bash script/linux/read.sh script/linux/read.sh 同一个绝对路径
/tmpbash /home/jie/.../read.sh /home/jie/.../read.sh 同一个绝对路径
script/linux/bash read.sh read.sh 同一个绝对路径

这一行的产出:一个与调用位置无关的"脚本所在目录"绝对路径。

第 2 行:从脚本目录往上跳两级,得到项目根

bash 复制代码
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

本项目的目录结构决定了"脚本目录往上两级"就是项目根:

复制代码
/home/jie/github/ouc_projects/sar_isar_mosaic     <- PROJECT_ROOT(项目根)
└── script
    └── linux                                     <- SCRIPT_DIR(脚本在这里)
        └── read.sh

$SCRIPT_DIR/../.. 代入,cd + pwd 再次把它规范化:

复制代码
/home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/../..
  -> /home/jie/github/ouc_projects/sar_isar_mosaic

按需调整跳级数 :脚本若放在项目根下,用 $SCRIPT_DIR/..;

放在 tools/xxx/yyy/ 这种三层深处,则用 $SCRIPT_DIR/../../..

原则:数一下脚本目录到项目根隔几层,就写几个 ..

第 3 行:真正切换过去

bash 复制代码
cd "$PROJECT_ROOT"

这次是不带 $() 的裸 cd,作用于脚本自身的 Shell 进程。此后脚本里

所有相对路径都以项目根为基准:

bash 复制代码
python src/read_file.py dataset/file.md
#     └── 以项目根为基准,任何位置运行都指向同一个文件

3. 完整流程演示

read.sh 全文:

bash 复制代码
set -euo pipefail                                      # ① 出错即停

# 定位项目根目录,保证相对路径(dataset/...、src/...)与运行位置无关
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"   # ② 脚本在哪
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"              # ③ 项目根在哪
cd "$PROJECT_ROOT"                                           # ④ 切过去

python src/read_file.py dataset/file.md                # ⑤ 干活

分别在两个不相干的目录运行,结果完全一致:

console 复制代码
$ cd ~ && bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
This is file.md!

$ cd /tmp && bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
This is file.md!

输出 This is file.md! 正是 dataset/file.md 的全部内容------尽管两次

运行的起点目录里既没有 src/ 也没有 dataset/

4. 延伸话题

4.1 set -euo pipefail 是什么

read.sh 第一行的三个安全开关,建议所有脚本的固定开头都带上:

选项 作用 没有它会怎样
-e 任何命令失败立即退出 cd 失败后继续用错误的相对路径执行后续命令
-u 引用未定义变量报错退出 变量名打错(如 $SCRIPT_DIR 写成 $SCIRPT_DIR)静默变成空串,路径错乱
-o pipefail 管道中任一环失败即整体失败 `命令A

对本文场景,-e 尤其重要:如果 cd "$PROJECT_ROOT" 失败(比如目录

被移动),脚本应立即停下,而不是带着错误的当前目录继续跑。

4.2 为什么变量要加双引号

bash 复制代码
cd "$SCRIPT_DIR/../.." && pwd    # ✅ 加引号
cd $SCRIPT_DIR/../.. && pwd      # ❌ 不加引号

不加引号时,Shell 会把变量值按空格拆分再展开。路径一旦含空格

(如 /home/jie/my projects/script),不加引号的版本会把

myprojects 当成两个词,cd 直接失败。路径变量永远加双引号

是无条件的习惯。

4.3 替代方案对比

定位项目根不止一种写法,各有适用场景:

① 本文方案:BASH_SOURCE + dirname + cd/pwd

bash 复制代码
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
  • 优点:零外部依赖,POSIX 习惯一致,任何装有 bash 的机器都能跑;
  • 缺点:跳级数 ../.. 与脚本所在深度耦合,移动脚本要改。

② 用 realpath 一步到位

bash 复制代码
PROJECT_ROOT="$(realpath "$(dirname "${BASH_SOURCE[0]}")/../..")"

realpath 直接输出规范化绝对路径,省去 cd && pwd。缺点是它是较新的

外部命令,老系统(如 CentOS 7 的精简环境)可能没有。

③ Git 项目专用:git rev-parse --show-toplevel

bash 复制代码
PROJECT_ROOT="$(git rev-parse --show-toplevel)"

直接问 Git 仓库的根目录在哪,不受脚本存放深度影响。缺点:必须在

Git 仓库内运行,且不适用于未纳入 Git 管理的项目。

④ 让 Python 自己找

既然最终执行的是 Python,也可以把定位问题下放给 read_file.py,

Shell 里只写相对调用。常见做法是从 __file__ 出发:

python 复制代码
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent  # src/ 的上一级

优点是逻辑跟代码走;缺点是 Shell 里若还有 cprm 等其他相对路径

操作,仍然需要 Shell 层面的定位。

本项目选择了方案 ① :脚本全部放在 script/linux/,深度固定,

且对运行环境零依赖,教学上也最适合逐层拆解。

4.4 已知局限

  • 符号链接 :通过软链接调用脚本时,BASH_SOURCE 指向链接路径而非
    真实文件,cd/pwd 不会解析链接。若需要穿透软链接,把 pwd 换成
    pwd -P,或改用 realpath;
  • source 加载 :被 source read.sh 加载时,BASH_SOURCE[0] 指向
    read.sh 本身(这正是它比 $0 可靠的原因),行为依然正确;但第 3 行
    cd改变 source 它的那个 Shell 的当前目录 ,这是 source
    与执行(subprocess)的本质区别,使用时需留意。

5. 总结

三行代码,一句话概括:

先用 BASH_SOURCE + dirname + cd/pwd 算出脚本自身的绝对位置,
再向上数 .. 得到项目根,最后 cd 过去------此后一切相对路径都有了
统一且唯一的基准。

记忆模板(任何项目直接套用):

bash 复制代码
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/<跳到根目录需要的 ../ 数>" && pwd)"
cd "$PROJECT_ROOT"
相关推荐
迷途之人不知返1 小时前
自制第一个Linux小程序——进度条
linux
zbyyd1 小时前
深入理解 Linux 文件 IO 与目录 IO:系统调用与库函数的本质区别
linux·c语言
Linux运维技术栈2 小时前
云平台服务器遭遇黑客攻击?用 Nginx 批量封锁敏感接口,自动返回 444 关闭连接
运维·服务器·nginx·安全·防御
qetfw2 小时前
Debian OpenLDAP 目录服务配置:DN、LDIF、ldapsearch 与认证验证
linux·运维·debian
词却2 小时前
Linux基础:Linux环境搭建与远程连接
linux
不悔哥2 小时前
Linux 小电视:稚晖君的开源桌面终端,从 6 层板到 Quark
linux
wzq11_6662 小时前
云计算运维学习day22——Ansible-Roles
运维·学习·云计算
HiDev_2 小时前
【非标自动化】2、认识元器件(数字量输入输出模块)
运维·自动化
qq_349447952 小时前
在ollama下部署DeepSeek-r1模型(linux机器)
linux·运维·服务器